Dica 38 - Django: Paginação + Filtros
Versões usadas no vídeo: Django 2.2, Python 3.8 e Bootstrap 4.
Importante: nas versões antigas desta página as tags de template apareciam com uma \ no meio ({%). Se copiar código de lá, remova a \.
Gist da paginação: https://gist.github.com/rg3915/01ca76f099f431c24bc0536bef83076b
Paginar uma lista no Django é fácil: basta um paginate_by na ListView. O problema aparece quando a lista também tem uma busca. Você busca por "an", vai para a página 2 e... o filtro some, porque o link da página é só ?page=2. Neste tutorial vamos:
- paginar a lista de pessoas;
- fazer o campo de busca funcionar;
- criar uma template tag
url_replaceque monta os links da paginação mantendo os outros parâmetros da URL (o filtro).
Pré-requisitos
Continuamos o projeto da Dica 37 - Faker: a app myproject/core tem o modelo Person (first_name, last_name, email, bio, birthday), uma PersonListView em /persons/, o template core/person_list.html com um formulário de busca (um <input name="search">) e uma tabela, e o comando create_data que cadastrou 100 pessoas com o Faker. A pasta myproject/core/templatetags (com o __init__.py) já existe desde a Dica 34 - Custom template tags.
Passo 1: paginando a ListView
Em views.py, acrescente paginate_by na PersonListView. O valor 5 é pequeno de propósito, para termos muitas páginas:
# myproject/core/views.py
class PersonListView(ListView):
model = Person
template_name = 'core/person_list.html'
paginate_by = 5
Com isso a página passa a mostrar só 5 pessoas, e a ListView coloca no contexto, além do object_list (agora só com os itens da página), o page_obj (a página atual), o paginator e o is_paginated. A página é escolhida pelo parâmetro ?page= da URL.
Passo 2: o template da paginação
Falta mostrar os links das páginas. Crie uma pasta includes dentro de templates e o arquivo pagination.html:
mkdir myproject/core/templates/includes
touch myproject/core/templates/includes/pagination.html
No final de person_list.html, logo depois da tabela, inclua a paginação:
<!-- myproject/core/templates/core/person_list.html -->
...
</table>
{% include "includes/pagination.html" %}
{% endblock content %}
No vídeo, o rodapé do base.html foi desativado (comentado), porque atrapalhava a visualização da paginação:
<!-- myproject/core/templates/base.html -->
...
<!-- { include "footer.html" %} -->
...
Agora o pagination.html, baseado no gist de paginação (ele usa as classes de paginação do Bootstrap 4). Esta é a primeira versão, ainda com os links simples ?page=:
<!-- myproject/core/templates/includes/pagination.html -->
<!-- https://gist.github.com/rg3915/01ca76f099f431c24bc0536bef83076b -->
<!-- Use https://gist.github.com/rg3915/01ca76f099f431c24bc0536bef83076b#file-pagination02-html -->
<div class="row text-center">
<div class="col-lg-12">
<ul class="pagination">
{% if page_obj.has_previous %}
<li class="page-item"><a class="page-link" href="?page={{ page_obj.previous_page_number }}">«</a></li>
{% endif %}
{% for pg in page_obj.paginator.page_range %}
<!-- Sempre mostra as 3 primeiras e 3 últimas páginas -->
{% if pg == 1 or pg == 2 or pg == 3 or pg == page_obj.paginator.num_pages or pg == page_obj.paginator.num_pages|add:'-1' or pg == page_obj.paginator.num_pages|add:'-2' %}
{% if page_obj.number == pg %}
<li class="page-item active"><a class="page-link" href="?page={{ pg }}">{{ pg }}</a></li>
{% else %}
<li class="page-item"><a class="page-link" href="?page={{ pg }}">{{ pg }}</a></li>
{% endif %}
{% else %}
{% if page_obj.number == pg %}
<li class="page-item active"><a class="page-link" href="?page={{ pg }}">{{ pg }}</a></li>
{% elif pg > page_obj.number|add:'-4' and pg < page_obj.number|add:'4' %} <!-- Mostra 3 páginas antes e 3 páginas depois da atual -->
<li class="page-item"><a class="page-link" href="?page={{ pg }}">{{ pg }}</a></li>
{% elif pg == page_obj.number|add:'-4' or pg == page_obj.number|add:'4' %}
<li class="page-item"><a class="page-link" href="">...</a></li>
{% endif %}
{% endif %}
{% endfor %}
{% if page_obj.has_next %}
<li class="page-item"><a class="page-link" href="?page={{ page_obj.next_page_number }}">»</a></li>
{% endif %}
</ul>
</div>
</div>
Como funciona:
«(«) aparece se existe página anterior (has_previous) e leva paraprevious_page_number;»(») é o equivalente para a próxima página.- O
forpercorre todas as páginas (page_range), mas não mostra todas. O primeiroifsempre mostra as 3 primeiras e as 3 últimas páginas. Como o Django não tem subtração no template,num_pages|add:'-1'faz o papel denum_pages - 1. - Para as páginas do meio, mostra só as que estão até 3 posições antes ou depois da página atual (
pg > number - 4 and pg < number + 4), e coloca reticências (...) na 4ª posição de cada lado, indicando que há páginas escondidas. - A página atual recebe a classe
activedo Bootstrap, que a destaca.
Rode o servidor e acesse /persons/: aparecem as 5 primeiras pessoas e, embaixo, 1 2 3 4 ... 18 19 20 » (com 100 pessoas e 5 por página são 20 páginas).
Passo 3: fazendo a busca funcionar
O formulário da lista envia o termo digitado no parâmetro search (é o name do <input>), pelo método GET: /persons/?search=an. Na view, sobrescreva o get_queryset para filtrar pelo termo:
# myproject/core/views.py
from django.db.models import Q
from django.views.generic import ListView
from .models import Person
class PersonListView(ListView):
model = Person
template_name = 'core/person_list.html'
paginate_by = 5
def get_queryset(self):
queryset = super(PersonListView, self).get_queryset()
data = self.request.GET
search = data.get('search')
if search:
queryset = queryset.filter(
Q(first_name__icontains=search) |
Q(last_name__icontains=search) |
Q(email__icontains=search) |
Q(bio__icontains=search)
)
return queryset
super().get_queryset()devolve o queryset padrão daListView(Person.objects.all()).self.request.GETé o dicionário com os parâmetros da URL;data.get('search')devolveNonese o parâmetro não existir, então o filtro só é aplicado quando há algo digitado.- Os objetos
Qunidos com|formam um OU: a pessoa entra no resultado se o termo aparecer no nome, no sobrenome, no e-mail ou na biografia. Oicontainsbusca o termo em qualquer parte do texto, sem diferenciar maiúsculas de minúsculas.
Busque por an: a lista é filtrada e paginada. Mas clique na página 2 e veja a URL: /persons/?page=2. O search=an sumiu e a lista voltou a mostrar todo mundo. É isso que vamos resolver agora.
Passo 4: a template tag url_replace
Precisamos de links que mantenham todos os parâmetros atuais da URL e troquem só o page. Crie a template tag:
touch myproject/core/templatetags/url_replace.py
# myproject/core/templatetags/url_replace.py
# https://stackoverflow.com/a/62587351/802542
from django import template
register = template.Library()
@register.simple_tag(takes_context=True)
def url_replace(context, **kwargs):
query = context['request'].GET.copy()
query.pop('page', None)
query.update(kwargs)
return query.urlencode()
Linha a linha:
@register.simple_tag(takes_context=True): registra uma simple tag que recebe o contexto do template como primeiro argumento. É pelo contexto que pegamos orequest(o context processordjango.template.context_processors.requestprecisa estar ativo emTEMPLATES, o que é o padrão dostartproject).context['request'].GET.copy(): orequest.GETé umQueryDictimutável; ocopy()devolve uma cópia que podemos alterar.query.pop('page', None): tira opageatual, se houver.query.update(kwargs): acrescenta os argumentos passados na tag, por exemplopage=3.query.urlencode(): devolve tudo como texto de URL, por exemplosearch=an&page=3.
Passo 5: usando a url_replace na paginação
Em pagination.html, carregue a tag no topo e troque todos os href="?page=..." por href="?{% url_replace page=... %}". O arquivo final fica assim:
<!-- myproject/core/templates/includes/pagination.html -->
{% load url_replace %}
<!-- https://gist.github.com/rg3915/01ca76f099f431c24bc0536bef83076b -->
<!-- Use https://gist.github.com/rg3915/01ca76f099f431c24bc0536bef83076b#file-pagination02-html -->
<div class="row text-center">
<div class="col-lg-12">
<ul class="pagination">
{% if page_obj.has_previous %}
<li class="page-item"><a class="page-link" href="?{% url_replace page=page_obj.previous_page_number %}">«</a></li>
{% endif %}
{% for pg in page_obj.paginator.page_range %}
<!-- Sempre mostra as 3 primeiras e 3 últimas páginas -->
{% if pg == 1 or pg == 2 or pg == 3 or pg == page_obj.paginator.num_pages or pg == page_obj.paginator.num_pages|add:'-1' or pg == page_obj.paginator.num_pages|add:'-2' %}
{% if page_obj.number == pg %}
<li class="page-item active"><a class="page-link" href="?{% url_replace page=pg %}">{{ pg }}</a></li>
{% else %}
<li class="page-item"><a class="page-link" href="?{% url_replace page=pg %}">{{ pg }}</a></li>
{% endif %}
{% else %}
{% if page_obj.number == pg %}
<li class="page-item active"><a class="page-link" href="?{% url_replace page=pg %}">{{ pg }}</a></li>
{% elif pg > page_obj.number|add:'-4' and pg < page_obj.number|add:'4' %} <!-- Mostra 3 páginas antes e 3 páginas depois da atual -->
<li class="page-item"><a class="page-link" href="?{% url_replace page=pg %}">{{ pg }}</a></li>
{% elif pg == page_obj.number|add:'-4' or pg == page_obj.number|add:'4' %}
<li class="page-item"><a class="page-link" href="">...</a></li>
{% endif %}
{% endif %}
{% endfor %}
{% if page_obj.has_next %}
<li class="page-item"><a class="page-link" href="?{% url_replace page=page_obj.next_page_number %}">»</a></li>
{% endif %}
</ul>
</div>
</div>
Note que o nome da biblioteca no {% load %} é o nome do arquivo (url_replace.py), e o nome da tag é o nome da função (url_replace). Depois de criar um arquivo novo em templatetags, reinicie o runserver.
Testando
Rode o servidor:
python manage.py runserver
Acesse /persons/, digite an na busca e clique em OK. A URL fica /persons/?search=an e, no vídeo, a busca retornou 16 páginas. Agora os links da paginação levam o filtro junto:
/persons/?search=an&page=2
/persons/?search=an&page=3
...
/persons/?search=an&page=16
Você navega pela página 2, 3, até a última, e o filtro continua aplicado em todas.
Conclusão
A paginação em si é só o paginate_by; o detalhe que costuma dar trabalho é preservar os outros parâmetros da URL. A url_replace resolve isso de forma genérica: ela funciona com qualquer filtro (busca, datas, categorias), porque copia todos os parâmetros do request.GET e troca só a página. No gist há outras variações do template de paginação.