Шаг 07. Фильтрация, поиск, сортировка и пагинация

📁 Серия: Капстоун C ⏱️ ~45 мин 🎯 Сложность: Средняя
#django-filter #SearchFilter #OrderingFilter #PageNumberPagination #FilterSet

⚡ Кратко: что делаем на этом шаге

Цель: Добавить к задачам фильтрацию по status, priority, project, assignee; полнотекстовый поиск; сортировку; пагинацию с настройкой глобально в settings.py.

  • Установка: pip install django-filter
  • Файлы: apps/tasks/filters.py (новый), apps/tasks/views.py (обновление), config/settings.py (пагинация + DjangoFilterBackend)
  • Проверка: GET /api/tasks/?status=todo&ordering=-created_at&page=1

🎯 Цель этапа

Реальные API не возвращают всё подряд — пользователь ищет задачи по статусу, проекту, исполнителю, сортирует по дате, листает страницы. На этом шаге мы добавляем три уровня управления выборкой:

  1. Фильтрация — точные совпадения по полям (django-filter + FilterSet)
  2. Поиск — полнотекстовый поиск в title/description (DRF SearchFilter)
  3. Сортировка — по любому разрешённому полю (DRF OrderingFilter)
  4. Пагинация — страничная навигация (PageNumberPagination глобально)

После этого шага у нас будет

  • GET /api/tasks/?status=todo&priority=high — фильтрация
  • GET /api/tasks/?search=авторизация — полнотекстовый поиск
  • GET /api/tasks/?ordering=-created_at — сортировка по убыванию
  • GET /api/tasks/?page=2&page_size=10 — постраничная навигация

📄 Затрагиваемые файлы

ФайлДействиеОписание
apps/tasks/filters.pyСоздатьTaskFilter — FilterSet с полями status, priority, project, assignee, due_date
apps/tasks/views.pyОбновитьПодключить filter_backends, filterset_class, search_fields, ordering_fields
config/settings.pyОбновитьDEFAULT_FILTER_BACKENDS, DEFAULT_PAGINATION_CLASS, PAGE_SIZE

🔨 Шаги

1. Установка django-filter

💻 Терминал
pip install django-filter
# Зафиксировать в requirements.txt
pip freeze | grep django-filter >> requirements.txt

2. Регистрация django_filters в INSTALLED_APPS

📄 config/settings.py
INSTALLED_APPS = [
    # ... Django apps ...
    "rest_framework",
    "django_filters",   # ← добавить
    "apps.users",
    "apps.projects",
    "apps.tasks",
]

3. Глобальные настройки DRF: фильтры и пагинация

📄 config/settings.py
REST_FRAMEWORK = {
    # ... существующие настройки ...

    # Фильтры — применяются ко всем ViewSet-ам по умолчанию
    "DEFAULT_FILTER_BACKENDS": [
        "django_filters.rest_framework.DjangoFilterBackend",
        "rest_framework.filters.SearchFilter",
        "rest_framework.filters.OrderingFilter",
    ],

    # Пагинация — PageNumberPagination со страницами по 20 записей
    "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
    "PAGE_SIZE": 20,
}
💡 Глобально vs per-ViewSet: Указывая DEFAULT_FILTER_BACKENDS в настройках, мы применяем фильтры ко всем ViewSet-ам проекта. Можно переопределить в конкретном ViewSet через атрибут filter_backends.

4. Создание FilterSet для задач

📄 apps/tasks/filters.py
# apps/tasks/filters.py
import django_filters
from .models import Task


class TaskFilter(django_filters.FilterSet):
    """
    Фильтр задач через django-filter.

    Поддерживаемые параметры:
      ?status=todo              — точное совпадение по статусу
      ?priority=high            — точное совпадение по приоритету
      ?project=1                — задачи конкретного проекта
      ?assignee=3               — задачи конкретного исполнителя
      ?due_date_before=2026-12-31  — срок до даты
      ?due_date_after=2026-06-01   — срок после даты
      ?is_overdue=true          — просроченные задачи
    """

    # Фильтры с точным совпадением — берут значение из TextChoices
    status = django_filters.ChoiceFilter(choices=Task.Status.choices)
    priority = django_filters.ChoiceFilter(choices=Task.Priority.choices)

    # FK-фильтры — сравнивают по ID связанного объекта
    project = django_filters.NumberFilter(field_name="project__id")
    assignee = django_filters.NumberFilter(field_name="assignee__id")

    # Диапазон дат
    due_date_before = django_filters.DateFilter(
        field_name="due_date", lookup_expr="lte"
    )
    due_date_after = django_filters.DateFilter(
        field_name="due_date", lookup_expr="gte"
    )

    class Meta:
        model = Task
        fields = ["status", "priority", "project", "assignee"]

5. Обновление TaskViewSet — подключение фильтрации

📄 apps/tasks/views.py
# apps/tasks/views.py
from rest_framework import viewsets
from rest_framework.filters import SearchFilter, OrderingFilter
from rest_framework.permissions import IsAuthenticatedOrReadOnly
from django_filters.rest_framework import DjangoFilterBackend
from .filters import TaskFilter
from .models import Task, Comment
from .serializers import (
    TaskListSerializer, TaskDetailSerializer, CommentSerializer,
)


class TaskViewSet(viewsets.ModelViewSet):
    """
    CRUD для задач с поддержкой фильтрации, поиска, сортировки и пагинации.

    Фильтрация:  ?status=todo&priority=high&project=1&assignee=2
    Поиск:       ?search=авторизация
    Сортировка:  ?ordering=-created_at  (минус = убывание)
    Пагинация:   ?page=2&page_size=10
    """

    permission_classes = [IsAuthenticatedOrReadOnly]

    # Бекенды фильтрации для этого ViewSet
    filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]

    # FilterSet-класс из filters.py
    filterset_class = TaskFilter

    # Поля для ?search= — ищет подстроку в title и description
    search_fields = ["title", "description"]

    # Поля для ?ordering= — разрешённые для сортировки
    ordering_fields = ["created_at", "updated_at", "due_date", "priority", "status"]

    # Сортировка по умолчанию (новые задачи первыми)
    ordering = ["-created_at"]

    def get_queryset(self):
        return (
            Task.objects
            .filter(is_deleted=False)
            .select_related("project", "assignee")
            .prefetch_related("comments")
        )

    def get_serializer_class(self):
        if self.action == "list":
            return TaskListSerializer
        return TaskDetailSerializer

    def perform_create(self, serializer):
        serializer.save()

    def perform_destroy(self, instance: Task):
        """Soft-delete: помечаем is_deleted=True, не удаляем из БД."""
        instance.is_deleted = True
        instance.save(update_fields=["is_deleted", "updated_at"])


class CommentViewSet(viewsets.ModelViewSet):
    """CRUD для комментариев."""

    serializer_class = CommentSerializer
    permission_classes = [IsAuthenticatedOrReadOnly]
    filter_backends = [DjangoFilterBackend, OrderingFilter]
    filterset_fields = ["task"]
    ordering_fields = ["created_at"]
    ordering = ["created_at"]

    def get_queryset(self):
        return Comment.objects.select_related("author", "task")
💡 Разница SearchFilter vs DjangoFilterBackend:
  • SearchFilter — один параметр ?search=, ищет подстроку сразу в нескольких полях через OR. Используй для «умного поиска».
  • DjangoFilterBackend — отдельный параметр для каждого поля (?status=todo), точное совпадение или заданный lookup. Используй для «точных фильтров».

6. Кастомный PageSize через query_param (опционально)

Если хочется позволить клиенту задавать размер страницы через ?page_size=10, можно создать кастомный класс пагинации:

📄 apps/tasks/pagination.py
# apps/tasks/pagination.py
from rest_framework.pagination import PageNumberPagination


class TaskPagination(PageNumberPagination):
    """
    Пагинация задач с возможностью задать page_size через query_param.

    Примеры:
        GET /api/tasks/?page=1             → 20 записей (по умолчанию)
        GET /api/tasks/?page=1&page_size=5 → 5 записей
        GET /api/tasks/?page=1&page_size=100 → не больше max_page_size
    """
    page_size = 20
    page_size_query_param = "page_size"
    max_page_size = 100

Подключаем в TaskViewSet:

from .pagination import TaskPagination

class TaskViewSet(viewsets.ModelViewSet):
    pagination_class = TaskPagination
    # ... остальные атрибуты без изменений ...

🧠 Объяснение логики

Как django-filter обрабатывает запрос

Когда клиент отправляет GET /api/tasks/?status=todo&priority=high, DRF проходит по списку filter_backends:

  1. DjangoFilterBackend берёт filterset_class = TaskFilter и применяет фильтры из URL-параметров к queryset
  2. SearchFilter проверяет наличие ?search= и фильтрует через LIKE '%...%' по полям из search_fields
  3. OrderingFilter проверяет ?ordering= и вызывает .order_by() на queryset

Все три бекенда работают вместе — фильтры не взаимоисключают друг друга.

ChoiceFilter vs CharFilter

Используем ChoiceFilter, а не просто CharFilter, потому что в TextChoices допустимы только конкретные значения. ChoiceFilter автоматически возвращает 400 Bad Request, если передан недопустимый вариант — это явная валидация входных данных.

Пагинация и структура ответа

С включённой пагинацией список задач больше не возвращает массив напрямую. Ответ оборачивается в объект:

{
  "count": 47,
  "next": "http://127.0.0.1:8000/api/tasks/?page=3",
  "previous": "http://127.0.0.1:8000/api/tasks/?page=1",
  "results": [ ... ]
}

count — общее число записей до пагинации (после фильтрации). next/previous — URL следующей/предыдущей страницы. results — записи текущей страницы.

⚠️ Изменение контракта API: Включение пагинации меняет структуру ответа — из массива [] в объект {"count": ..., "results": []}. Если у вас уже есть клиент, который ожидает массив — его нужно обновить. Закладывайте пагинацию с самого начала, не добавляйте постфактум без версионирования API.

select_related и фильтрация

get_queryset() возвращает queryset с select_related. django-filter применяет фильтры поверх этого queryset через дополнительные .filter()-вызовы. Django ORM объединяет все условия в один SQL-запрос. Производительность не страдает.

✅ Проверка

1. Запуск сервера

💻 Терминал
python manage.py runserver

2. Пагинация — список задач

💻 curl
curl -s "http://127.0.0.1:8000/api/tasks/" | python -m json.tool
Ожидаемый ответ (структура с пагинацией):
{
  "count": 5,
  "next": null,
  "previous": null,
  "results": [
    {
      "id": 1,
      "title": "Первая задача",
      "status": "todo",
      "priority": "medium"
    }
  ]
}

3. Фильтрация по статусу

💻 curl
curl -s "http://127.0.0.1:8000/api/tasks/?status=todo" | python -m json.tool

4. Комбинированная фильтрация

💻 curl
curl -s "http://127.0.0.1:8000/api/tasks/?status=todo&priority=high&ordering=-created_at" | python -m json.tool

5. Полнотекстовый поиск

💻 curl
curl -s "http://127.0.0.1:8000/api/tasks/?search=авторизация" | python -m json.tool

6. Пагинация с кастомным размером страницы

💻 curl
curl -s "http://127.0.0.1:8000/api/tasks/?page=1&page_size=5" | python -m json.tool

Диагностика ошибок

  • ImproperlyConfigured: django_filters not found — добавьте "django_filters" в INSTALLED_APPS
  • AssertionError: filterset_class requires... — проверьте импорт TaskFilter в views.py
  • 400 при ?status=INVALID — это нормально, ChoiceFilter отклоняет невалидные значения
  • Пагинация не работает — убедитесь, что DEFAULT_PAGINATION_CLASS задан в REST_FRAMEWORK

➡️ Что дальше

Задачи теперь фильтруемы, поддерживают поиск и пагинацию. Следующий шаг — защита API через JWT-аутентификацию. Без неё любой может создать, изменить или удалить данные. SimpleJWT даёт нам access + refresh токены в стиле OAuth 2.0.

  • Готово: фильтрация по status/priority/project/assignee, поиск, сортировка, пагинация
  • Шаг 08: SimpleJWT — установка, настройка, регистрация и логин, кастомный payload