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.
Links numerados sem listar todas as páginas
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.