Как сделать простой пагинатор на Django и HTMx. Добавляем сортировку и фильтры ч. 2

Часы
08.04.2025
Часы
15.04.2025
Часы
9 минут
Глазик
48
Сердечки
0
Соединённые точки
0
Соединённые точки
0
Соединённые точки
0

Вступление

Любой пагинатор хорош на столько, на сколько хороши его фильтры. В этой статье я покажу как можно "органически" встроить любые фильтры и сортировку на твой пагинатор. Этот пагинатор будет написан с использованием HTMx. Который в свою очередь будет общаться с API нашего сайта, настроенный при помощи Django REST framework.
Фильтры и сортировщики, которые мы добавим к пагинатору будут статическими. Что я имею в виду. Под статическими фильтрами и сортировщиками я имею в виду, те фильтры, для применения которых потребуется нажать кнопку сортировки. То есть, сначала выбираем необходимые опции, потом жмякаем соответствующую кнопку.
Я мог бы, сделать его динамическим, но для этого потребуется писать дополнительный JS-код и сильно усложнять реализацию пагинатора и это уже не будет статья про "добавление фильтров к простому пагинатору". Не о том статья.
То, как будут выглядеть фильтры на странице

Пишем фронтенд

Наши фильтры и сортировщики располагаются в одном месте и обрамлены тегом form. Принципиальной разницы между ими нет. По крайней мере, на стороне клиента оформляться они будут одинаково.
Создадим шаблон, где будут отрисовываться наши фильтры и сортировщики. Я назвал его paginator-filters.html и разместил в parts/ директории. Сейчас разберём что же там такое написано. Вот полный код шаблона:
{% load static %}
{% load i18n %}

<form id="filters-sorters-form" class="flex flex-col gap-[4px]">
<input class="hidden" name="page" value="1"/>
<div class="menu menu-horizontal bg-base-200 rounded-box self-center items-center w-full">
<li class="menu-title flex-auto w-full">{% trans 'Фильтры' %}</li>
<li>
<details close>
<summary hx-get="api/categories/" hx-trigger="load" hx-target="#categories-container">{% trans 'Категории' %}</summary>
<ul id="categories-container" class="max-h-[300px] z-[1] overflow-y-scroll w-max">
</ul>
</details>
</li>
<li>
<details close>
<summary hx-get="api/periods/" hx-trigger="load" hx-target="#periods-container">{% trans 'Периоды' %}</summary>
<ul id="periods-container" class="max-h-[300px] z-[1] overflow-y-scroll w-max">
</ul>
</details>
</li>
<li>
<details close>
<summary hx-get="api/epochs/" hx-trigger="load" hx-target="#epochs-container">{% trans 'Эпохи' %}</summary>
<ul id="epochs-container" class="max-h-[300px] z-[1] overflow-y-scroll w-max">
</ul>
</details>
</li>
<li>
<details close>
<summary hx-get="api/figures/" hx-trigger="load" hx-target="#figures-container">{% trans 'Люди' %}</summary>
<ul id="figures-container" class="max-h-[300px] z-[1] overflow-y-scroll w-max">
</ul>
</details>
</li>
<li class="filter flex flex-row gap-[4px] items-center">
{% if with_pdf %}
<input id="with-pdf" type="checkbox" name="with_pdf" checked="checked">
{% else %}
<input id="with-pdf" type="checkbox" name="with_pdf">
{% endif %}
<label for="with-pdf">{% trans 'Есть PDF' %}</label>
</li>
<li class="filter flex flex-row gap-[4px] items-center">
{% if with_audio %}
<input id="with-audio" type="checkbox" name="with_audio" checked="checked">
{% else %}
<input id="with-audio" type="checkbox" name="with_audio">
{% endif %}
<label for="with-audio">{% trans 'Есть подкаст' %}</label>
</li>
</div>
<div class="flex-auto"></div>
<div class="menu menu-horizontal bg-base-200 rounded-box self-center items-center w-full">
<li class="menu-title flex-auto w-full">{% trans 'Сортировка' %}</li>
<li class="filter flex flex-row gap-[4px] items-center">
{% if alphabetic_sort %}
<input id="alphabetic-sort" type="checkbox" name="alphabetic_sort" checked="checked">
{% else %}
<input id="alphabetic-sort" type="checkbox" name="alphabetic_sort">
{% endif %}
<label for="alphabetic-sort">{% trans 'A-Z Сотрировка' %}</label>
</li>
<li class="filter flex flex-row gap-[4px] items-center">
{% if last_pub_sort %}
<input id="last-pub-sort" type="checkbox" name="last_pub_sort" checked="checked">
{% else %}
<input id="last-pub-sort" type="checkbox" name="last_pub_sort">
{% endif %}
<label for="last-pub-sort">{% trans 'По дате публикации, c первых' %}</label>
</li>
<li class="filter flex flex-row gap-[4px] items-center">
{% if last_upd_sort %}
<input id="last-upd-sort" type="checkbox" name="last_upd_sort" checked="checked">
{% else %}
<input id="last-upd-sort" type="checkbox" name="last_upd_sort">
{% endif %}
<label for="last-upd-sort">{% trans 'По дате обновления, с первых' %}</label>
</li>
</div>
<button
type="submit"
for="filters-sorters-form"
class="btn">
{% trans 'Фильтровать' %}
</button>
</form>
<a href="/{{LANGUAGE_CODE}}/"><button class="btn btn-error w-full">
{% trans 'Очистить' %}
</button></a>
В этой форме две кнопки. Одна отправляет запрос с параметрами, другая отправляет запрос без параметров, то есть очищает фильтры.
...
<button
type="submit"
for="filters-sorters-form"
class="btn">
{% trans 'Фильтровать' %}
</button>
</form>
<a href="/{{LANGUAGE_CODE}}/"><button class="btn btn-error w-full">
{% trans 'Очистить' %}
</button></a>
Та, что выделена отправляет запрос с параметрами, то есть фильтрует выдачу
С тем, как и откуда отправляются запросы о фильтрации и сортировки мы разобрались. Разберём теперь что, и как мы отправляем на сервер.
В самом начале есть скрытое поле ввода, которое содержит номер страницы. Он всегда 1 кстати. Почему начинать фильтрацию с первой страницы, а не, скажем, с текущей? Вообще это возможно сделать, просто это приведёт к сильному усложнению кода на сервере и лишним проверкам на то, чтобы не выйти из-за границ возможного. Например, нам доступно только 5 страниц пагинации, по результатам фильтрации, а мы сейчас на 6-й.
После номера страницы, форма разделена (условно, конечно) на часть фильтров и часть сортировщиков. Давай сначала посмотрим на сортировщики:
<div class="menu menu-horizontal bg-base-200 rounded-box self-center items-center w-full">
<li class="menu-title flex-auto w-full">{% trans 'Сортировка' %}</li>
<li class="filter flex flex-row gap-[4px] items-center">
{% if alphabetic_sort %}
<input id="alphabetic-sort" type="checkbox" name="alphabetic_sort" checked="checked">
{% else %}
<input id="alphabetic-sort" type="checkbox" name="alphabetic_sort">
{% endif %}
<label for="alphabetic-sort">{% trans 'A-Z Сотрировка' %}</label>
</li>
<li class="filter flex flex-row gap-[4px] items-center">
{% if last_pub_sort %}
<input id="last-pub-sort" type="checkbox" name="last_pub_sort" checked="checked">
{% else %}
<input id="last-pub-sort" type="checkbox" name="last_pub_sort">
{% endif %}
<label for="last-pub-sort">{% trans 'По дате публикации, c первых' %}</label>
</li>
<li class="filter flex flex-row gap-[4px] items-center">
{% if last_upd_sort %}
<input id="last-upd-sort" type="checkbox" name="last_upd_sort" checked="checked">
{% else %}
<input id="last-upd-sort" type="checkbox" name="last_upd_sort">
{% endif %}
<label for="last-upd-sort">{% trans 'По дате обновления, с первых' %}</label>
</li>
</div>
Первое, что хочу отметить это переменные шаблонов, такие как alphabetic_sort, last_pub_sort и last_upd_sort. Они определяются и возвращаются сервером. И если они определены, то поля ввода будут уже активны. Так же в input элементе я определяю имя(name) отправляемого параметра, оно же и будет именем переменной в шаблоне.
С сортировщиками всё, перейдём к фильтрам. В блоке фильтров ты можешь найти два их типа:
  1. Простые, это те которые работают точно так же как и сортировщики. Такая же разметка.
  2. Сложные, это те, которые должны будут сделать предварительный запрос на сервер, чтобы узнать, какие элементы фильтрации отобразить.
<li>
<details close>
<summary hx-get="api/epochs/" hx-trigger="load" hx-target="#epochs-container">{% trans 'Эпохи' %}</summary>
<ul id="epochs-container" class="max-h-[300px] z-[1] overflow-y-scroll w-max">
</ul>
</details>
</li>
Пример сложного фильтра.
Я делаю GET-запрос, по адресу "api/epochs/" используя hx-get атрибут. Атрибут hx-tirgger, указывает когда, нужно сделать данный запрос. И указывается целевой элемент в который будет происходить загрузка ответа с сервера, данный целевой элемент указывается при помощи hx-target атрибут.
Все названия и их модификаторы для триггеров отправки запросов в HTMx можно найти здесь, на официальном сайте или посмотреть тут.
Как можно заметить, я обращаюсь к API моего сайта, чтобы получить все возможные варианты фильтрации по данному критерию, то есть эпохи. Чтобы их отрендерить, потребуется написать соответствующий шаблон, назовём его paginator-filter-items.html и положим его в parts/ директорию.
{% load static %}
{% load i18n %}

{% for item in queryset %}
<li name="filter" class="filter flex flex-row gap-[4px] items-center">
{% if key_filters %}
{% if item.slug not in key_filters %}
<input id="{{parameter}}-{{item.slug}}" type="checkbox" name="{{parameter}}" value="{{item.slug}}" class=""/>
{% else %}
<input id="{{parameter}}-{{item.slug}}" type="checkbox" name="{{parameter}}" value="{{item.slug}}" class="" checked="checked"/>
{% endif %}
{% else %}
<input id="{{parameter}}-{{item.slug}}" type="checkbox" name="{{parameter}}" value="{{item.slug}}" class=""/>
{% endif %}
<label for="{{parameter}}-{{item.slug}}">{{item.name}}</label>
</li>
{% endfor %}
Этот шаблон отрисует все имеющиеся элементы фильтрации и пометит активные поля ввода.
Хочу заметить. Под всеми имеющимися элементами фильтрации я подразумеваю, django-модели и их конкретные записи в БД. По которым можно отфильтровать статьи.
С фронтендом мы почти закончили, нужно лишь обновить файл paginator-buttons.html. И в каждом атрибуте hx-post, в конец, добавить следующую переменную шаблона, base_url. Это делается для того, чтобы при перемещении между страницами сохранялась фильтрация и сортировка.
Вот обновлённый файл:
{% load i18n %}
{% load static %}

<form id="pagination_buttons" class="join self-center">
{% csrf_token %}
{% if is_articles_prev %}
<button
class="join-item btn"
hx-post="?page=1{{base_url}}"
hx-target="#search-results"
hx-replace-url="true"
value="1"
name="page"
>
{% trans '<<' %}
</button>
<button
class="join-item btn"
hx-post="?page={{articles_prev_page}}{{base_url}}"
hx-target="#search-results"
hx-replace-url="true"
value="{{articles_prev_page}}"
name="page"
>
{% trans '<' %}
</button>
{% else %}
<button class="join-item btn btn-disabled">{% trans '<<' %}</button>
<button class="join-item btn btn-disabled">{% trans '<' %}</button>
{% endif %}

<button class="join-item btn btn-disabled">{{articles_start_page}}/{{articles_length}}</button>

{% if is_articles_next%}
<button
class="join-item btn"
hx-post="?page={{articles_next_page}}{{base_url}}"
hx-target="#pagination_buttons"
hx-swap="outerHTML"
hx-replace-url="true"
value="{{articles_next_page}}"
name="page"
>
{% trans '>' %}
</button>
<button
class="join-item btn"
hx-post="?page={{articles_length}}{{base_url}}"
hx-target="#search-results"
hx-replace-url="true"
value="{{articles_length}}"
name="page"
>
{% trans '>>' %}
</button>
{% else %}
<button class="join-item btn btn-disabled">{% trans '>' %}</button>
<button class="join-item btn btn-disabled">{% trans '>>' %}</button>
{% endif %}
</form>
Жёлтым помечены те строки, где произошли изменения.

Пишем бэкенд

Вся суть клиентской части фильтров и сортировщиков состоит в том, чтобы построить соответствующий URL и отправить его на сервер, где сервер уже разбирается что и как вернуть пользователю.
Всё это будет происходить в ранее написанном классе-представлении HomeView, в файле Frontend/views.py. Который мы подключили в Frontend/urls.py.
from django.urls import path
from .views import SideView, HomeView

urlpatterns = [
path('', HomeView.as_view(), name='domashnyaya'),
path('o-proekte/', SideView.as_view(template_name="o-proekte.html"), name='o-proekte'),
]
Теперь взглянем, как я изменил HomeView класс:
import urllib
from django.shortcuts import render
from django.http import Http404
from django.core.paginator import Paginator
from django.utils.translation import gettext as _
from django.utils.translation import get_language
from django.views.generic import TemplateView
from Backend.models import *



class HomeView(TemplateView):

template_name = 'domashnyaya.html'
page_size = 2

def get_context_data(self, **kwargs):
context = super(HomeView, self).get_context_data(**kwargs)

return context
def fetch(self, request, context, page_num, *args, **kwargs):
# Update base URL for paginator
referer_url = request.get_full_path()
url = urllib.parse.urlparse(referer_url)
query = urllib.parse.parse_qs(url.query)
# Filter out needed objects
# Filter out other languages versions, and time published, base filtering and sorting
articles_by_lang = Article.objects.filter(lang_type=get_language())
articles = articles_by_lang.order_by("-time_published")
for key, values in query.items():
match key:
case "category":
categories = Category.objects.filter(lang_type=get_language()).filter(slug__in=values)
articles = articles.filter(categories__in=categories).distinct()
case "period":
periods = Period.objects.filter(lang_type=get_language()).filter(slug__in=values)
articles = articles.filter(periods__in=periods).distinct()
case "epoch":
epochs = Epoch.objects.filter(lang_type=get_language()).filter(slug__in=values)
articles = articles.filter(epoch__in=epochs).distinct()
case "figure":
figures = Figure.objects.filter(lang_type=get_language()).filter(slug__in=values)
articles = articles.filter(figures__in=figures).distinct()
case "with_pdf":
articles = articles.filter(is_pdf_version=True)
case "with_audio":
articles = articles.filter(is_audio_version=True)
case "alphabetic_sort":
articles = articles.order_by("header")
case "last_pub_sort":
articles = articles.order_by("time_published")
case "last_upd_sort":
articles = articles.order_by("-time_updated")
# Create a paginator, build-in Django one
paginator = Paginator(articles, self.page_size)
pagination_length = paginator.num_pages
# Check if page out of range
if page_num > pagination_length or page_num <= 0:
raise Http404()
# Paginate to page
page = paginator.page(page_num)
is_prev = page.has_previous()
is_next = page.has_next()
page_articles = page.object_list

if len(query) <= 0:
base_url = ""
else:
base_url = "&"
for key, values in query.items():
if key != 'page':
for value in values:
base_url += f"{key}={value}&"
context.update({key: True})
base_url = base_url[:-1]

# Update context
context.update({
'base_url': base_url,
'articles': page_articles,
'articles_start_page': page_num,
'articles_next_page': page_num + 1,
'articles_prev_page': page_num - 1,
'articles_length': pagination_length,
'is_articles_next': is_next,
'is_articles_prev': is_prev
})

def post(self, request, *args, **kwargs):
context = super(HomeView, self).get_context_data(**kwargs)
page_num = int(request.POST.get('page', 1))
self.fetch(request, context, page_num, *args, **kwargs)
return render(request, 'parts/paginator/article-simple-paginator.html', context=context)

def get(self, request, *args, **kwargs):
context = super(HomeView, self).get_context_data(**kwargs)
page_num = int(request.GET.get('page', 1))
self.fetch(request, context, page_num, *args, **kwargs)
return render(request, self.template_name, context=context)
В данном файле я добавил создание базового URL адреса, который присоединяется к hx-post атрибуту, в кнопках пагинации. Работает он довольно просто. Берёт адрес запроса, разбивает его на пары ключ-значение и соединяет их обратно, но только без указания номера страницы.
Ой и чуть не забыл. Я так же добавил логику фильтрации и сортировки статей. Она в самом начале. Пройдёмся по порядку:
referer_url = request.get_full_path()
url = urllib.parse.urlparse(referer_url)
query = urllib.parse.parse_qs(url.query)
Здесь, я извлекаю сам запрос переменными в URL-адресе.
articles_by_lang = Article.objects.filter(lang_type=get_language())
articles = articles_by_lang.order_by("-time_published")
Тут, происходит базовая фильтрация и сортировка по дате публикации (Сначала отображаются самые новые статьи).
После я провожу непосредственную фильтрацию и сортировку по тем параметрам, которые были отправлены через URL.
for key, values in query.items():
match key:
case "category":
categories = Category.objects.filter(lang_type=get_language()).filter(slug__in=values)
articles = articles.filter(categories__in=categories).distinct()
case "period":
periods = Period.objects.filter(lang_type=get_language()).filter(slug__in=values)
articles = articles.filter(periods__in=periods).distinct()
case "epoch":
epochs = Epoch.objects.filter(lang_type=get_language()).filter(slug__in=values)
articles = articles.filter(epoch__in=epochs).distinct()
case "figure":
figures = Figure.objects.filter(lang_type=get_language()).filter(slug__in=values)
articles = articles.filter(figures__in=figures).distinct()
case "with_pdf":
articles = articles.filter(is_pdf_version=True)
case "with_audio":
articles = articles.filter(is_audio_version=True)
case "alphabetic_sort":
articles = articles.order_by("header")
case "last_pub_sort":
articles = articles.order_by("time_published")
case "last_upd_sort":
articles = articles.order_by("-time_updated")
Данная сортировка работает по принципу "трубы". То есть, каждый последующий фильтр работает с выходными данными предыдущего фильтра. Таким образом остаются только те статьи, которые прошли через все фильтры.
При фильтрации объектов моделей Figure, Epoch, Period и Category я использую параметры с суффиксом на __in. Он означает, что нужно найти все записи в базе данных в указанном списке. Он может вернуть дубликаты записей, поэтому после метода filter я добавляю метод distinct. Он уберёт все дубликаты.
Оператор match, доступен только в версиях Python 3.10 и выше. По этому если ты используешь версию ниже, то увы ┐( ̄~ ̄)┌, переходи на if-else.
Ещё нужно рассмотреть такой класс-представление как FiltersView. Это представление, которое работает и возвращает html-странички со списком всех доступных фильтров по записям в моделях Figure, Epoch, Period и Category.
Данный класс-представление необязательно к разработке и имплементации. Но он очень удобен, если в модели, по которой ты хочешь пагинироваться, есть поля взаимоотношений между моделями, такие как ManyToMany или ForeignKey.
Вот его код FiltersView класса:
class FiltersView(generics.ListAPIView):
renderer_classes = [TemplateHTMLRenderer]
template_name = 'parts/paginator/paginator-filter-items.html'
parameter = ""

def get(self, request):
referer_url = request.META['HTTP_REFERER']
url = urllib.parse.urlparse(referer_url)
query = urllib.parse.parse_qs(url.query)
context = {}

if len(query) > 0:
values_to_update = []
for key, values in query.items():
for value in values:
if key == 'page':
pass
elif key != self.parameter:
pass
else:
values_to_update.append(value)
context.update({f'key_filters': values_to_update})

queryset = self.get_queryset().filter(lang_type=get_language())

context.update({
'queryset': queryset,
'parameter': self.parameter,
})

return Response(context)
Основной задачей данного представления является "фильтрация фильтров" по языку и пометкой их при необходимости в шаблоне (то есть, помечать их чекнутыми если они уже используются). Его ещё нужно подключить к маршрутизатору в Backend/urls.py:
from django.urls import path, include
from rest_framework import routers
from Backend import views, models


urlpatterns = [
path('api/categories/', views.FiltersView.as_view(parameter="category", serializer_class=views.CategorySerializer, queryset = models.Category.objects.all())),
path('api/epochs/', views.FiltersView.as_view(parameter="epoch", serializer_class=views.EpochSerializer, queryset = models.Epoch.objects.all())),
path('api/periods/', views.FiltersView.as_view(parameter="period", serializer_class=views.PeriodSerializer, queryset = models.Period.objects.all())),
path('api/figures/', views.FiltersView.as_view(parameter="figure", serializer_class=views.FigureSerializer, queryset = models.Figure.objects.all())),
]

При подключении FiltersView, нужно указать следующие параметры:
  1. Имя параметра (оно будет использоваться в URL)
  2. Класс сериализатора
  3. Список объектов с которыми будет работать FilterView класс

Выводы

Вот, что я хотел бы сказать, по поводу фильтров на страницах пагинатора. Это не сложно, если не усложнять и придумывать колесо заново. Как ты мог заметить, я использовал HTMx по минимуму, а REST API почти не использовал, потому что это не требовалось. Мы создаём простой пагинатор, а не сложно-универсальный убер-пупер ультимативный поиск. Вот.


Комментарии

(0)

captcha
Отправить
Сейчас тут пусто. Буть первым (o゚v゚)ノ

Другое

Похожие статьи


О дупликатах и неканонических страницах при имплементации пагинатора

Часы
02.03.2025
Глазик
65
Сердечки
0
Соединённые точки
0
Соединённые точки
0
Соединённые точки
0
В этой статье я на примере своего сайта покажу, что такое дублированный(не канонический) контент появившийся в Google search console в результате внедрения пагинатора или бесконечной ленты. Со статистикой и графикой. …

Как сделать кнопку загрузки контента используя Django, REST API, HTMx

Часы
01.04.2025
Глазик
53
Сердечки
0
Соединённые точки
0
Соединённые точки
0
Соединённые точки
0
В этой статье я опишу способ, как можно реализовать асинхронную загрузку контента(статей) при помощи кнопки "Больше", которая сделана при помощи Django, REST API, HTMx и стилизовано при помощи DaisyUI

Как сделать простой пагинатор на Django и HTMx ч. 1

Часы
02.04.2025
Глазик
62
Сердечки
0
Соединённые точки
0
Соединённые точки
0
Соединённые точки
0
В этой статье я опишу то, как создать пагинатор используя Django и HTMx библиотеку. И то, почему это было так просто в сравнении с пагинатором на моём сайте. С шаблонами …

Как сделать простой пагинатор на Django и HTMx. Добавляем сортировку и фильтры ч. 2

Часы
08.04.2025
Глазик
48
Сердечки
0
Соединённые точки
0
Соединённые точки
0
Соединённые точки
0
В этой статье я опишу процесс и основные блоки кода, для того чтобы добавить сортировку и фильтрацию к пагинатору. Данный пагинатор написан на Django используя HTMx.

Как добавить форму обратной связи на Django, HTMx

Часы
11.04.2025
Глазик
37
Сердечки
0
Соединённые точки
0
Соединённые точки
0
Соединённые точки
0
В этой статье, я опишу как добавить на ваш Django-сайт форму обратной связи используя HTMx и немного DaisyUI, в качестве UI-библиотеки. Всё будет сделано на примере моего нового сайта. Но …

Как кастомизировать 404 и 500 страницы ответов в Django

Часы
12.04.2025
Глазик
70
Сердечки
0
Соединённые точки
0
Соединённые точки
0
Соединённые точки
0
В этой статье я опишу процесс кастомизации таких страниц как 404 и 500. Я покажу два основных способа как это сделать и то как можно быстро и легко настроить сервер …

Использованные термины


Релевантные вопросы