Django Filters with Pagination

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">&laquo; 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 &raquo;</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 %}">&laquo; 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 &raquo;</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.