Pular para o conteúdo
_lvleo21
← Todos os artigos

Como paginar listas e APIs no Django?

Paginação com ListView e paginate_by, os atributos do page_obj no template, links numerados, filtros preservados na URL e paginação de APIs com Django REST Framework.

  • django
  • python

Publiquei este artigo originalmente no Medium em junho de 2020. Nesta versão revisei o código para o Django atual, corrigi alguns erros do texto original e acrescentei recursos que surgiram depois, como o get_elided_page_range e a tag {% querystring %}.

Neste artigo vamos implementar paginação em um projeto Django usando class based views e mostrar como usar os dados da página no template. Parto do princípio de que você já conhece o básico de Django, class based views e HTML.

Os exemplos usam um model chamado People, com nome, idade e gênero:

# models.py
from django.db import models


class People(models.Model):
    name = models.CharField(max_length=100)
    age = models.PositiveIntegerField()
    gender = models.CharField(max_length=20)

    def __str__(self):
        return self.name

A view

A ListView, das generic views do Django, representa uma lista de objetos e já sabe paginar. Basta definir três atributos: model, com o model que vai ser listado; template_name, com o HTML que recebe os dados; e paginate_by, com a quantidade de objetos por página.

# views.py
from django.views.generic import ListView

from .models import People


class PeopleListView(ListView):
    model = People
    template_name = "people/people_list.html"
    paginate_by = 2
    ordering = ["name"]

O ordering não aparecia na primeira versão deste artigo e faz falta. Sem uma ordem definida, o banco pode devolver os registros em qualquer sequência, e a mesma pessoa pode aparecer em duas páginas enquanto outra some. O Django avisa sobre isso com um UnorderedObjectListWarning quando você pagina um queryset sem ordenação. Também dá para resolver com ordering no Meta do model.

Depois é só registrar a view no urls.py:

# urls.py
from django.urls import path

from .views import PeopleListView

urlpatterns = [
    path("pessoas/", PeopleListView.as_view(), name="people-list"),
]

A página atual vem do parâmetro page da URL: /pessoas/?page=3 mostra a terceira página. A ListView também aceita ?page=last e responde com 404 quando o número não existe, então você não precisa tratar esses casos na mão.

O que chega no template

Com paginate_by definido, a view coloca no contexto do template o page_obj (a página atual), o paginator (o objeto que dividiu a lista) e o is_paginated, que só é verdadeiro quando existe mais de uma página.

Na versão original eu escrevi que o page_obj contém todos os objetos do banco. Não contém: ele guarda só os objetos da página atual. Com dez pessoas cadastradas e paginate_by = 2, cada página tem duas pessoas e a listagem fica com cinco páginas. Os atributos mais úteis são estes:

Atributo O que retorna
page_obj.number Número da página atual
page_obj.has_next True se existe uma página seguinte
page_obj.has_previous True se existe uma página anterior
page_obj.next_page_number Número da próxima página (na página 3, retorna 4)
page_obj.previous_page_number Número da página anterior (na página 3, retorna 2)
page_obj.start_index / end_index Posição do primeiro e do último item da página na lista completa
page_obj.paginator.num_pages Total de páginas
page_obj.paginator.count Total de objetos em todas as páginas
page_obj.paginator.page_range Intervalo com todos os números de página; com cinco páginas, equivale a range(1, 6)

next_page_number e previous_page_number levantam EmptyPage quando a página não existe. Por isso eles sempre aparecem dentro de um if com has_next ou has_previous nos exemplos abaixo.

Montando o template

A listagem é um for sobre o page_obj. O {% empty %} cobre o caso de não haver ninguém cadastrado:

{% for person in page_obj %}
  <p>Nome: <b>{{ person.name }}</b></p>
  <p>Idade: <b>{{ person.age }}</b></p>
  <p>Gênero: <b>{{ person.gender }}</b></p>
{% empty %}
  <p>Nenhuma pessoa cadastrada.</p>
{% endfor %}

A navegação tem um link para a página anterior, que só aparece se ela existir, um contador no meio e um link para a próxima página. O href="?page=..." mantém o caminho atual e troca só o número da página:

{% if is_paginated %}
  <nav aria-label="Paginação">
    {% if page_obj.has_previous %}
      <a href="?page={{ page_obj.previous_page_number }}">Página anterior</a>
    {% endif %}

    <span>Página {{ page_obj.number }} de {{ page_obj.paginator.num_pages }}</span>

    {% if page_obj.has_next %}
      <a href="?page={{ page_obj.next_page_number }}">Próxima página</a>
    {% endif %}
  </nav>
{% endif %}

Com dez pessoas e duas por página, a primeira tela mostra duas pessoas, o texto “Página 1 de 5” e só o link de próxima página.

Para mostrar os números das páginas, o caminho óbvio é iterar sobre page_range. Funciona com cinco páginas, mas com trezentas vira uma fileira enorme de links. Desde o Django 3.2 o paginator tem o get_elided_page_range, que devolve só as páginas próximas da atual e das pontas, com reticências no meio (por exemplo, 1 … 7 8 9 10 11 … 300).

Como o método recebe o número da página atual, ele precisa ser chamado na view:

class PeopleListView(ListView):
    model = People
    template_name = "people/people_list.html"
    paginate_by = 2
    ordering = ["name"]

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        page = context["page_obj"]
        context["page_range"] = page.paginator.get_elided_page_range(
            page.number, on_each_side=2, on_ends=1
        )
        return context

No template, as reticências vêm como paginator.ELLIPSIS, então dá para diferenciá-las dos números:

{% for number in page_range %}
  {% if number == page_obj.paginator.ELLIPSIS %}
    <span>{{ number }}</span>
  {% elif number == page_obj.number %}
    <span aria-current="page">{{ number }}</span>
  {% else %}
    <a href="?page={{ number }}">{{ number }}</a>
  {% endif %}
{% endfor %}

Preservando filtros na URL

O href="?page=2" tem um problema que só aparece quando a listagem ganha busca ou filtros. Se a URL for /pessoas/?q=ana&page=1, o link de próxima página leva para /pessoas/?page=2 e a busca se perde.

O Django 5.1 trouxe a tag {% querystring %}, que copia os parâmetros da URL atual e troca só os que você passar. Ela já devolve a string começando com ?:

{% if page_obj.has_next %}
  <a href="{% querystring page=page_obj.next_page_number %}">Próxima página</a>
{% endif %}

Em /pessoas/?q=ana&page=1, esse link aponta para ?q=ana&page=2. Em versões anteriores ao 5.1, a saída era escrever uma template tag própria que fizesse a mesma coisa com request.GET.copy().

Paginação em APIs com Django REST Framework

Quando a listagem é um endpoint de API, quem pagina é o Django REST Framework, que tem classes próprias para isso. Para ligar em todas as views de uma vez, basta configurar no settings.py:

REST_FRAMEWORK = {
    "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
    "PAGE_SIZE": 20,
}

A resposta passa a vir com o total, os links das páginas vizinhas e os resultados da página atual:

{
  "count": 10,
  "next": "https://api.exemplo.com/pessoas/?page=2",
  "previous": null,
  "results": []
}

Para deixar o cliente escolher o tamanho da página sem abrir espaço para um page_size=100000, crie uma subclasse com um limite:

from rest_framework.pagination import PageNumberPagination


class PeoplePagination(PageNumberPagination):
    page_size = 20
    page_size_query_param = "page_size"
    max_page_size = 100

E use na view com pagination_class = PeoplePagination.

A paginação por número de página faz um COUNT(*) a cada requisição para calcular o total. Em tabelas grandes essa contagem pesa, e registros inseridos enquanto o usuário navega deslocam os itens entre as páginas. Nesses casos a alternativa é a CursorPagination, que navega por um cursor opaco em vez de números. Ela não informa o total nem permite pular para uma página específica, e exige uma ordenação por um campo único que não muda, como a data de criação:

from rest_framework.pagination import CursorPagination


class PeopleCursorPagination(CursorPagination):
    page_size = 20
    ordering = "-created_at"

O created_at desse exemplo supõe um campo de data de criação no model, que o People do início do artigo não tem.

Referências