Django, HTMX, class based generic views, querysets and pagination
I think this is as much a question about minimalism and efficiency, but anyway...
I have a generic ListView that I'm using, along with HTMX which I'm a first time user of, but loving it so far! That said, I have some quirks here with the default behavior of a generic class based view that I'm not sure how to handle. Considering the following...
class AccountListView(ListView):
model = Account
template_name = 'account_list.html'
paginate_by = 100
def get_queryset(self):
query = self.request.POST.get('query')
try:
query = int(query)
except:
pass
if query:
if isinstance(query, int):
return Account.objects.filter(
Q(id=query)
)
else:
return Account.objects.filter(
Q(full_name__icontains=query) | Q(email1=query) | Q(email2=query) | Q(email3=query)
).order_by('-date_created', '-id')
return Account.objects.all().order_by('-date_created', '-id')
def post(self, request, *args, **kwargs):
response = super().get(self, request, *args, **kwargs)
context = response.context_data
is_htmx = request.headers.get('HX-Request') == 'true'
if is_htmx:
return render(request, self.template_name + '#account_list', context)
return response
def get(self, request, *args, **kwargs):
response = super().get(self, request, *args, **kwargs)
context = response.context_data
is_htmx = request.headers.get('HX-Request') == 'true'
if is_htmx:
return render(request, self.template_name + '#account_list', context)
return response
As you can likely gather, my issue here is I'm trying to implement two different functionalities in a single generic view...
- a quick-search, that checks whether the user has submitted an integer (and if so search for an account ID) or if not an integer, also let the user search for a full name or email.
- also, I am using template partials to provide for infinite scroll of the list view itself, by checking whether or not HTMX is present in the request
The infinite scroll part of this works fine, what doesn't work fine anymore, after the addition of HTMX to the mix, is my quick search. I can gather that this is because the URL of self
is changing behind the scenes, and I wind up hitting a paginated queryset instead of the full queryset at some point...
File "C:\Users\xxxxxx xxxxxxx\AppData\Local\Packages\PythonSoftwareFoundation.Python.3.13_qbz5n2kfra8p0\LocalCache\local-packages\Python313\site-packages\django\template\base.py", line 977, in render_annotated
return self.render(context)
~~~~~~~~~~~^^^^^^^^^
File "C:\Users\xxxxxx xxxxxxx\AppData\Local\Packages\PythonSoftwareFoundation.Python.3.13_qbz5n2kfra8p0\LocalCache\local-packages\Python313\site-packages\django\template\base.py", line 1075, in render
output = self.filter_expression.resolve(context)
File "C:\Users\xxxxxx xxxxxxx\AppData\Local\Packages\PythonSoftwareFoundation.Python.3.13_qbz5n2kfra8p0\LocalCache\local-packages\Python313\site-packages\django\template\base.py", line 722, in resolve
obj = self.var.resolve(context)
File "C:\Users\xxxxxx xxxxxxx\AppData\Local\Packages\PythonSoftwareFoundation.Python.3.13_qbz5n2kfra8p0\LocalCache\local-packages\Python313\site-packages\django\template\base.py", line 854, in resolve
value = self._resolve_lookup(context)
File "C:\Users\xxxxxx xxxxxxx\AppData\Local\Packages\PythonSoftwareFoundation.Python.3.13_qbz5n2kfra8p0\LocalCache\local-packages\Python313\site-packages\django\template\base.py", line 925, in _resolve_lookup
current = current()
File "C:\Users\xxxxxx xxxxxxx\AppData\Local\Packages\PythonSoftwareFoundation.Python.3.13_qbz5n2kfra8p0\LocalCache\local-packages\Python313\site-packages\django\core\paginator.py", line 215, in next_page_number
return self.paginator.validate_number(self.number + 1)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^
File "C:\Users\xxxxxx xxxxxxx\AppData\Local\Packages\PythonSoftwareFoundation.Python.3.13_qbz5n2kfra8p0\LocalCache\local-packages\Python313\site-packages\django\core\paginator.py", line 71, in validate_number
raise EmptyPage(self.error_messages["no_results"])
django.core.paginator.EmptyPage: That page contains no results
[24/Jul/2025 02:18:56] "POST /search HTTP/1.1" 500 177746
So the question is what's the most efficient way to handle catch22s like this with Django and HTMX living alongside each other in generic views?
Solved, it's a matter of checking whether the partial template to be injected is paginated or not, django correctly applies is_paginated = False
to the default post method in the generic view....
-> import pdb;pdb.set_trace()
(Pdb) context
{'paginator': <django.core.paginator.Paginator object at 0x000001AE88947750>,
'page_obj': <Page 1 of 1>, 'is_paginated': False, 'object_list': <QuerySet [<Account: XYZ Company>]>, 'account_list': <QuerySet [<Account: XYZ Company>]>, 'view': <accounts.views.AccountListView object at 0x000001AE88947610>}
So therefore, adding this to the template logic to check and see what we're doing with the results fixes it. Previously my conditional check in the template was limited to {% if forloop.last %}
{% startpartial account_list %}
{% for item in object_list %}
{% if forloop.last %}
<tr hx-get="accounts?page={{ page_obj.next_page_number }}"
hx-trigger="revealed"
hx-swap="afterend">
{% else %}
<tr>
{% endif %}
<th scope="row">{{item.id}}</th>
<td>{{ item.account_type }}</td>
<td>{{ item.first_name }}</td>
<td>{{ item.last_name }}</td>
<td><a href="mailto:{{ item.email1 }}">{{ item.email1 }}</a></td>
<td>{{ item.organization_name }}</td>
<td><a href="tel:{{ item.mobile_phone }}">{{item.mobile_phone.as_national}}</a></td>
<td>{{ item.date_created }}
</tr>
{% endfor %}
{% endpartial %}
Changing this to also consider {% is paginated %}
fixes it, by causing the template to ignore the HTMX paginated output when returning the search results.
{% startpartial account_list %}
{% for item in object_list %}
{% if forloop.last and is_paginated %}
<tr hx-get="accounts?page={{ page_obj.next_page_number }}"
hx-trigger="revealed"
hx-swap="afterend">
{% else %}
<tr>
{% endif %}
<th scope="row">{{item.id}}</th>
<td>{{ item.account_type }}</td>
<td>{{ item.first_name }}</td>
<td>{{ item.last_name }}</td>
<td><a href="mailto:{{ item.email1 }}">{{ item.email1 }}</a></td>
<td>{{ item.organization_name }}</td>
<td><a href="tel:{{ item.mobile_phone }}">{{item.mobile_phone.as_national}}</a></td>
<td>{{ item.date_created }}
</tr>
{% endfor %}
{% endpartial %}