Мы прошли четыре урока по CBV: TemplateView, ListView, DetailView, CreateView, UpdateView, DeleteView. Все они хорошо работают по отдельности, но в реальном проекте один класс часто должен делать несколько вещей одновременно: отображать список и обрабатывать форму фильтрации, требовать авторизации и логировать действия. Для этого Django предлагает Mixins.
Вторая тема урока — пагинация. В прошлом уроке мы включили её в ListView через paginate_by, а сейчас разберём механику изнутри и реализуем вручную для FBV.
Mixin — это класс, предназначенный не для самостоятельного использования, а для добавления одной конкретной способности другому классу через множественное наследование. В Python множественное наследование выглядит так:
class FilmListView(LoginRequiredMixin, ListView):
...LoginRequiredMixin не является полноценным CBV — у него нет метода get() для обработки запроса. Он только добавляет одну способность: проверять авторизацию перед любым запросом. Полноценный ListView берёт на себя всю логику работы со списком. Вместе они дают класс, который и требует авторизации, и показывает список.
Mixins — это не изобретение Django. Это распространённый паттерн Python-разработки. В Django он используется особенно активно, потому что CBV построены на нём с самого начала:
CreateView,UpdateView— это сами по себе комбинации Mixin-классов.
Пожалуй, самый используемый. Перенаправляет неавторизованного пользователя на страницу входа:
from django.contrib.auth.mixins import LoginRequiredMixin
class FilmCreateView(LoginRequiredMixin, CreateView):
model = Film
form_class = FilmForm
template_name = 'films/add_film.html'
login_url = '/login/' # куда перенаправить (можно настроить глобально в settings.py)Если login_url не указан — Django использует значение settings.LOGIN_URL (по умолчанию /accounts/login/). Подключим LoginRequiredMixin полноценно в модуле 7, когда настроим авторизацию. Сейчас важно понять механику.
Порядок Mixin'ов важен: LoginRequiredMixin должен стоять перед основным CBV-классом. Это связано с порядком разрешения методов в Python (MRO — Method Resolution Order): Django ищет методы слева направо по цепочке наследования, и проверка авторизации должна произойти до того, как основной класс начнёт обрабатывать запрос.
# Правильно — LoginRequiredMixin проверяет авторизацию первым
class FilmCreateView(LoginRequiredMixin, CreateView):
...
# Неправильно — CreateView обработает запрос до проверки авторизации
class FilmCreateView(CreateView, LoginRequiredMixin):
...from django.contrib.auth.mixins import PermissionRequiredMixin
class FilmDeleteView(LoginRequiredMixin, PermissionRequiredMixin, DeleteView):
model = Film
permission_required = 'films.delete_film' # <app_label>.<action>_<model>
template_name = 'films/film_confirm_delete.html'
success_url = reverse_lazy('films:film_list')permission_required принимает строку '<app_label>.<permission>'. Django автоматически создаёт четыре стандартных разрешения для каждой модели при выполнении миграций: add_film, change_film, delete_film, view_film. Разрешениями и группами мы займёмся подробно в модуле 7.
Вспомним: у нас в нескольких CBV есть одинаковый get_queryset():
# В FilmListView
def get_queryset(self):
return Film.objects.select_related('director').prefetch_related('genres', 'actors')
# В FilmDetailView — то же самое
def get_queryset(self):
return Film.objects.select_related('director').prefetch_related('genres', 'actors')Вынесем это в Mixin:
# films/mixins.py
from .models import Film
class FilmQuerySetMixin:
"""Добавляет оптимизированный QuerySet для запросов к Film."""
def get_queryset(self):
return Film.objects.select_related('director').prefetch_related('genres', 'actors')Применяем:
from .mixins import FilmQuerySetMixin
class FilmListView(FilmQuerySetMixin, ListView):
model = Film
template_name = 'films/film_list.html'
context_object_name = 'films'
paginate_by = 10
class FilmDetailView(FilmQuerySetMixin, DetailView):
model = Film
template_name = 'films/film_detail.html'
context_object_name = 'film'
def get(self, request, *args, **kwargs):
response = super().get(request, *args, **kwargs)
FilmStats.objects.filter(film=self.object).update(views_count=F('views_count') + 1)
return responseТеперь логика select_related/prefetch_related живёт в одном месте. Если добавится ещё одна связь — поменяем в одном файле, а не в двух классах.
Другой частый случай — добавить в контекст данные, нужные на многих страницах. Например, список последних фильмов для боковой панели:
# films/mixins.py
class RecentFilmsMixin:
"""Добавляет список последних фильмов в контекст любого CBV."""
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['recent_films'] = Film.objects.recent(3)
return contextclass FilmDetailView(FilmQuerySetMixin, RecentFilmsMixin, DetailView):
model = Film
template_name = 'films/film_detail.html'
context_object_name = 'film'super().get_context_data(**kwargs) в Mixin — это ключевой элемент корректной цепочки. Когда несколько классов переопределяют один и тот же метод, super() идёт по MRO и вызывает следующий метод в цепочке, гарантируя, что все Mixin'ы получат шанс добавить своё содержимое в контекст.
В уроке 20 мы добавили paginate_by = 10 и получили пагинацию «из коробки». Посмотрим, что Django добавляет в контекст:
| Переменная | Что содержит |
|---|---|
page_obj |
Объект текущей страницы (Page) |
paginator |
Объект пагинатора (Paginator) |
is_paginated |
True, если записей больше, чем paginate_by |
object_list |
Объекты на текущей странице |
Полноценный блок пагинации для шаблона:
<!-- films/templates/films/includes/pagination.html -->
{% if is_paginated %}
<nav class="pagination">
{% if page_obj.has_previous %}
<a href="?page=1">« Первая</a>
<a href="?page={{ page_obj.previous_page_number }}">← Назад</a>
{% endif %}
{% for page_num in paginator.page_range %}
{% if page_num == page_obj.number %}
<span class="current">{{ page_num }}</span>
{% elif page_num >= page_obj.number|add:"-2" and page_num <= page_obj.number|add:"2" %}
<a href="?page={{ page_num }}">{{ page_num }}</a>
{% endif %}
{% endfor %}
{% if page_obj.has_next %}
<a href="?page={{ page_obj.next_page_number }}">Вперёд →</a>
<a href="?page={{ paginator.num_pages }}">Последняя »</a>
{% endif %}
<span class="info">
Страница {{ page_obj.number }} из {{ paginator.num_pages }}
(всего записей: {{ paginator.count }})
</span>
</nav>
{% endif %}Подключаем в film_list.html через {% include %}:
{% extends 'base.html' %}
{% block content %}
<h1>Каталог фильмов</h1>
{% for film in films %}
{% include 'films/includes/film_card.html' %}
{% empty %}
<p>В каталоге пока нет фильмов.</p>
{% endfor %}
{% include 'films/includes/pagination.html' %}
{% endblock %}Вынос пагинации в отдельный include — хорошая практика: она понадобится и на странице результатов поиска, и на странице списка рецензий.
ListView использует встроенный класс Paginator. Разберём его, чтобы применять в FBV — например, для представления search_film, которое пока осталось функцией.
# films/views.py
from django.core.paginator import Paginator, InvalidPage
from django.http import Http404
def search_film(request):
query = request.GET.get('q', '').strip()
films = Film.objects.none()
if query:
films = Film.objects.search(query)
paginator = Paginator(films, 10) # по 10 фильмов на странице
page_number = request.GET.get('page', 1) # номер страницы из GET-параметра
try:
page_obj = paginator.page(page_number)
except InvalidPage:
raise Http404('Страница не найдена')
context = {
'films': page_obj.object_list, # объекты текущей страницы
'page_obj': page_obj,
'paginator': paginator,
'is_paginated': paginator.num_pages > 1,
'query': query,
}
return render(request, 'films/search_results.html', context)Разберём Paginator:
paginator = Paginator(queryset, per_page) # принимает QuerySet и размер страницы
page = paginator.page(page_number) # возвращает объект Page для конкретной страницы
page.object_list # объекты на этой странице
page.has_previous() / page.has_next() # булевы флаги
page.previous_page_number() # номер предыдущей страницы
page.next_page_number() # номер следующей страницы
paginator.num_pages # всего страниц
paginator.count # всего объектовИменно с этими переменными и работает шаблон пагинации, который мы вынесли в includes/pagination.html — поэтому он одинаково работает как для ListView (где page_obj добавляется автоматически), так и для FBV (где мы передаём его вручную).
Есть тонкий момент: если пользователь на странице /films/search/?q=коппола кликает «Следующая страница», ссылка должна быть /films/search/?q=коппола&page=2, а не просто /films/search/?page=2. Иначе параметр q потеряется и вместо второй страницы результатов поиска появится вторая страница всех фильмов.
Решение — в шаблоне включать параметр q в ссылки пагинации:
<!-- Для страницы поиска параметр q нужно сохранить в ссылках пагинации -->
<a href="?q={{ query }}&page={{ page_obj.next_page_number }}">Вперёд →</a>Для общего includes/pagination.html удобнее использовать django-querystring-based подход или передавать дополнительные GET-параметры через контекст. Простейший вариант — сделать отдельный шаблон пагинации для поиска с учётом query:
<!-- films/templates/films/includes/pagination_search.html -->
{% if is_paginated %}
<nav class="pagination">
{% if page_obj.has_previous %}
<a href="?q={{ query }}&page={{ page_obj.previous_page_number }}">← Назад</a>
{% endif %}
<span>{{ page_obj.number }} / {{ paginator.num_pages }}</span>
{% if page_obj.has_next %}
<a href="?q={{ query }}&page={{ page_obj.next_page_number }}">Вперёд →</a>
{% endif %}
</nav>
{% endif %}# films/mixins.py
from .models import Film
class FilmQuerySetMixin:
def get_queryset(self):
return Film.objects.select_related('director').prefetch_related('genres', 'actors')
class RecentFilmsMixin:
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['recent_films'] = Film.objects.recent(3)
return context# films/views.py (CBV-часть)
from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
from django.views.generic import ListView, DetailView
from django.views.generic.edit import CreateView, UpdateView, DeleteView
from django.db.models import F
from django.urls import reverse_lazy
from .mixins import FilmQuerySetMixin, RecentFilmsMixin
from .models import Film, FilmStats
from .forms import FilmForm
class FilmListView(FilmQuerySetMixin, ListView):
model = Film
template_name = 'films/film_list.html'
context_object_name = 'films'
paginate_by = 10
class FilmDetailView(FilmQuerySetMixin, RecentFilmsMixin, DetailView):
model = Film
template_name = 'films/film_detail.html'
context_object_name = 'film'
def get(self, request, *args, **kwargs):
response = super().get(request, *args, **kwargs)
FilmStats.objects.filter(film=self.object).update(views_count=F('views_count') + 1)
return response
class FilmCreateView(LoginRequiredMixin, CreateView):
model = Film
form_class = FilmForm
template_name = 'films/add_film.html'
class FilmUpdateView(LoginRequiredMixin, UpdateView):
model = Film
form_class = FilmForm
template_name = 'films/film_edit.html'
context_object_name = 'film'
def get_success_url(self):
return self.object.get_absolute_url()
class FilmDeleteView(LoginRequiredMixin, PermissionRequiredMixin, DeleteView):
model = Film
permission_required = 'films.delete_film'
template_name = 'films/film_confirm_delete.html'
context_object_name = 'film'
success_url = reverse_lazy('films:film_list')# LoginRequiredMixin должен идти первым
class FilmCreateView(CreateView, LoginRequiredMixin):
...
# Mixin'ы идут слева от основного CBV-класса
class FilmCreateView(LoginRequiredMixin, CreateView):
...Правило: специализирующие Mixin'ы — слева, основной класс представления — последний.
Если Mixin переопределяет get_context_data() без вызова super() — он обрывает цепочку, и следующий Mixin в MRO не получит возможности добавить свои данные:
# Обрывает MRO-цепочку — данные других Mixin'ов потеряются
class RecentFilmsMixin:
def get_context_data(self, **kwargs):
return {'recent_films': Film.objects.recent(3)}
# Сохраняет цепочку — данные всех Mixin'ов объединятся
class RecentFilmsMixin:
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['recent_films'] = Film.objects.recent(3)
return contextПользователь может вручную ввести в адресную строку /films/?page=99999, если страниц 10. Без обработки InvalidPage — Django выбросит необработанное исключение, а не 404. Всегда оборачивайте paginator.page() в try/except InvalidPage.
- Что такое Mixin и почему его нельзя использовать самостоятельно как представление?
- Почему порядок Mixin'ов в списке наследования имеет значение?
- Почему
super().get_context_data(**kwargs)внутри Mixin критически важен? - Что такое
InvalidPageи почему нужно его обрабатывать при ручной пагинации? - Почему при пагинации в форме поиска важно включать параметр
qв ссылки на другие страницы?
Тип: расширь проект
Создай собственный Mixin FilmEditMixin, который объединяет общие атрибуты для FilmCreateView и FilmUpdateView.
Требования:
- Создай
FilmEditMixinвfilms/mixins.pyс атрибутамиmodel,form_class,template_name, которые повторяются в обоих классах - Примени его к
FilmCreateViewиFilmUpdateView— каждый класс должен стать заметно короче - Добавь в
FilmListViewMixinGenreListMixin, который добавляет в контекст список всех жанров (genres) — он понадобится, когда мы добавим фильтрацию по жанру на страницу каталога