Django Filters with Pagination
How to add pagination at the bottom of the page
As I mentioned in my last post I tend to always have this desire to go back to Django, and I'm sort of acting on that now. I wrote a full API for the weight tracking app that I mentioned in that post in FastAPI only to realize that I didn't want to deal with the baggage of a front end framework for something that should work very well in an MVC setup. Thankfully Django and FastAPI are both Python so a lot of the functionality ports over well.
That said, I did run into a weird little hiccup earlier today and I thought I'd share what I ended up doing. I have a view that lists out all foods that are either system foods or user-created foods, but as I have a fairly large database of foods to begin with, since I had kept this in a spreadsheet before and I scraped all of the restaurant foods from exercise4weightloss.com/weight-watchers-poi.., it amounted to a list of just shy of 50,000 foods so I clearly needed to add pagination.
Pagination
This is easy to do in Django if you're using a ListView in that you only need to add one line to your class-based view
# food/views.py
from django.db.models import QuerySet, Q
from django.views.generic import ListView
from .models import Food
class FoodListView(ListView):
model = Food
paginate_by = 20
def get_queryset(self) -> QuerySet[Food]:
self.queryset = Food.objects.filter(
Q(user_created=False) | Q(user=self.request.user)
)
return super().get_queryset()
The get_queryset
method is to set it to return all system foods and the foods created by the logged in user.
Then you simply need to update your template to include pagination as described in docs.djangoproject.com/en/4.0/topics/pagina..
{# food_list.html #}
{% extends 'base.html' %}
{% block content %}
<ul>
{% for food in food_list %}
<li>{{ food }}</li>
{% endfor %}
</ul>
<div class="pagination">
<span class="step-links">
{% if page_obj.has_previous %}
<a href="?page=1">« first</a>
<a href="?page={{ page_obj.previous_page_number }}">previous</a>
{% endif %}
<span class="current">
Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}
</span>
{% if page_obj.has_next %}
<a href="?page={{ page_obj.next_page_number }}">next</a>
<a href="?page={{ page_obj.paginator.num_pages }}">last »</a>
{% endif %}
</span>
</div>
{% endblock content %}
Filtering
If you want to include filtering, though, and you don't want to manually handle it, this can create a problem.
Replacing Generic ListView
The first thing you would need to do is install django-filter. Then after that you can simply create a filter and replace your ListView
with a FilterView
# food/views.py
import django_filters
from django.db.models import QuerySet, Q
from django_filters.views import FilterView
from .models import Food
class FoodFilter(django_filters.FilterSet):
name = django_filters.CharFilter(lookup_expr="icontains")
class Meta:
model = Food
fields = ["name"]
class FoodListView(FilterView):
model = Food
paginate_by = 20
filterset_class = FoodFilter
template_name_suffix = "_list"
def get_queryset(self) -> QuerySet[Food]:
self.queryset = Food.objects.filter(
Q(user_created=False) | Q(user=self.request.user)
)
return super().get_queryset()
This, in theory, should return the same functionality as before, but now we can call http://localhost:8000/foods/?name=chocolate
or anything like that and it will properly filter the queryset.
The problem
The issue that we run into, though, has to do with going to the next page. Currently, the next and previous page links on the template don't have any way of knowing what the current query is. So, while the first link will go to http://localhost:8000/foods/?name=chocolate
but if I click on the "next page" link it will go to http://localhost:8000/foods/?page=2
and it will forget about the filter that I applied.
The solution
The simplest way I found to do this, as of this moment, is two-fold.
Update the context
Within the view itself we need to add a get_context_data
method
class FoodListView(FilterView):
model = Food
paginate_by = 20
filterset_class = FoodFilter
template_name_suffix = "_list"
def get_queryset(self) -> QuerySet[Food]:
self.queryset = Food.objects.filter(
Q(user_created=False) | Q(user=self.request.user)
)
return super().get_queryset()
def get_context_data(self, **kwargs: Any) -> Dict[str, Any]:
context = super().get_context_data(**kwargs)
context["query"] = dict()
for k, v in context["filter"].data.items():
if k != "page":
context["query"][k] = v
return context
What this will do is it will check to see what the items are in the QueryDict
that Django filter uses, and create a query
object on the context that will include all existing query parameters but not including any that have page
as the key because we want to let the built-in pagination handle that.
Update the template
Then we just update the template to include all of these query objects on the next
and prev
anchor tags:
{# food_list.html #}
{% extends 'base.html' %}
{% block content %}
<ul>
{% for food in food_list %}
<li>{{ food }}</li>
{% endfor %}
</ul>
<div class="pagination">
<span class="step-links">
{% if page_obj.has_previous %}
<a href="?page=1{% for k, v in query.items %}&{{ k }}={{ v }}{% endfor %}">« first</a>
<a href="?page={{ page_obj.previous_page_number }}{% for k, v in query.items %}&{{ k }}={{ v }}{% endfor %}">previous</a>
{% endif %}
<span class="current">
Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}
</span>
{% if page_obj.has_next %}
<a href="?page={{ page_obj.next_page_number }}{% for k, v in query.items %}&{{ k }}={{ v }}{% endfor %}">next</a>
<a href="?page={{ page_obj.paginator.num_pages }}{% for k, v in query.items %}&{{ k }}={{ v }}{% endfor %}">last »</a>
{% endif %}
</span>
</div>
{% endblock content %}
What this will now do is append an &k=v
for each key/value pair that previously existed in the Django filter query. I'm sure I'm going to abstract this out into a snippet that will be more easily reusable down the road, but for now this was the easiest way to get it working quickly.