ListView, DetailView, CreateView, UpdateView, DeleteView, FormView
Generic CBV реализуют повторяющийся HTTP-сценарий: показать список, найти объект, отобразить форму, обработать ошибки и перенаправить после успеха. Они сокращают инфраструктурный код, но не принимают за разработчика решения о доступе, составе полей и границах транзакции.
Материал актуален для Django 5.2 LTS и 6.0.
У класса есть несколько точек настройки:
model, template_name, paginate_by;get_queryset() определяет видимые объекты;get_context_data() дополняет контекст шаблона;form_valid() выполняет работу после успешной валидации;get_success_url() вычисляет адрес перенаправления.Переопределяйте наиболее узкую подходящую точку. Полная замена dispatch() или post() часто случайно выбрасывает уже реализованную обработку формы.
from django.views.generic import ListView
from .models import Post
class PostListView(ListView):
template_name = "blog/post_list.html"
context_object_name = "posts"
paginate_by = 20
def get_queryset(self):
return (
Post.objects.published()
.select_related("author", "category")
.prefetch_related("tags")
.order_by("-published_at", "-pk")
)ListView передаст posts, page_obj, paginator и is_paginated. Стабильная сортировка важна для пагинации. get_queryset() вызывается на запрос и подходит для параметров URL и текущего пользователя.
Атрибут queryset тоже поддерживается:
queryset = Post.objects.published()Django клонирует его через .all(), поэтому это не общий кэш результатов между запросами. Но метод яснее, когда фильтр зависит от self.request или self.kwargs.
Простая ссылка ?page=2 удалит фильтры поиска. Удобно передавать строку параметров без page из view или создать безопасный template tag. Нельзя собирать URL простой конкатенацией неэкранированного ввода.
from django.views.generic import DetailView
class PostDetailView(DetailView):
template_name = "blog/post_detail.html"
context_object_name = "post"
slug_field = "slug"
slug_url_kwarg = "slug"
def get_queryset(self):
return (
Post.objects.published()
.select_related("author", "category")
.prefetch_related("tags")
)Объект ищется внутри get_queryset(). Поэтому черновик не раскроется по прямому URL. Отдельная проверка после get_object() хуже: она может отличаться между GET и POST или выдать лишнюю информацию о существовании записи.
Дополнительный контекст строится от уже найденного self.object:
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["related_posts"] = (
Post.objects.published()
.filter(category=self.object.category)
.exclude(pk=self.object.pk)[:4]
)
return contextИспользуйте ту же доменную выборку published(), что и в основном списке. Несогласованные флаги is_published и status создают трудноуловимые утечки.
from django.contrib.auth.mixins import LoginRequiredMixin
from django.urls import reverse
from django.views.generic import CreateView
from .forms import PostForm
class PostCreateView(LoginRequiredMixin, CreateView):
form_class = PostForm
template_name = "blog/post_form.html"
def form_valid(self, form):
form.instance.author = self.request.user
return super().form_valid(form)
def get_success_url(self):
return reverse("blog:post_detail", kwargs={"slug": self.object.slug})CreateView сохранит форму и положит созданную модель в self.object. Для динамического URL внутри метода используют reverse(). reverse_lazy() нужен прежде всего для атрибутов класса, которые вычисляются во время импорта.
Не используйте fields = "__all__" для пользовательской формы. При добавлении служебного поля в модель оно внезапно станет редактируемым. Явный ModelForm фиксирует публичный контракт.
from django.contrib.auth.mixins import LoginRequiredMixin
from django.db import transaction
from django.views.generic import UpdateView
from .models import PostRevision
class PostUpdateView(LoginRequiredMixin, UpdateView):
form_class = PostForm
template_name = "blog/post_form.html"
def get_queryset(self):
queryset = Post.objects.all()
if self.request.user.is_staff:
return queryset
return queryset.filter(author=self.request.user)
def form_valid(self, form):
old_body = self.object.body
with transaction.atomic():
response = super().form_valid(form)
if old_body != self.object.body:
PostRevision.objects.create(
post=self.object,
old_body=old_body,
new_body=self.object.body,
changed_by=self.request.user,
)
return responseСкрытие ссылки в шаблоне не является авторизацией. Фильтр get_queryset() применяется и при показе, и при отправке формы. Транзакция не допускает сохранения статьи без обязательной ревизии.
Этот пример всё ещё использует last-write-wins. Если два редактора могут открыть одну версию, добавьте version-поле и оптимистическую проверку либо блокировку, исходя из продукта.
Стандартный DeleteView показывает подтверждение на GET и удаляет на POST. Изменять данные по GET нельзя:
<form method="post">
{% csrf_token %}
<p>Удалить «{{ object.title }}»?</p>
<button type="submit">Удалить</button>
</form>Доступ снова ограничивается через get_queryset(). Для физического удаления достаточно success_url:
from django.urls import reverse_lazy
from django.views.generic import DeleteView
class PostDeleteView(LoginRequiredMixin, DeleteView):
template_name = "blog/post_confirm_delete.html"
success_url = reverse_lazy("blog:post_list")
def get_queryset(self):
return Post.objects.filter(author=self.request.user)В современном DeleteView POST проходит через форму, а удаление выполняется в form_valid(). Поэтому мягкое удаление переопределяет именно его:
from django.http import HttpResponseRedirect
from django.utils import timezone
class PostArchiveView(LoginRequiredMixin, DeleteView):
model = Post
template_name = "blog/post_confirm_archive.html"
success_url = reverse_lazy("blog:post_list")
def get_queryset(self):
return Post.objects.filter(author=self.request.user, deleted_at__isnull=True)
def form_valid(self, form):
success_url = self.get_success_url()
self.object.deleted_at = timezone.now()
self.object.save(update_fields=["deleted_at"])
return HttpResponseRedirect(success_url)Soft delete требует согласованного QuerySet/Manager, правил уникальности и поведения связей. Один флаг не решает эти вопросы.
FormView подходит поиску, импорту, контакту или другой форме, где нет одного стандартного ModelForm.save():
from django.urls import reverse_lazy
from django.views.generic import FormView
class ContactView(FormView):
template_name = "contact/form.html"
form_class = ContactForm
success_url = reverse_lazy("contact_done")
def form_valid(self, form):
form.enqueue_message()
return super().form_valid(form)Метод формы здесь может создать запись outbox или поставить задачу после commit. Отправка письма прямо в HTTP-запросе увеличивает задержку и усложняет повтор при сбое.
Generic views рассчитаны прежде всего на одну форму. Если регистрация сохраняет пользователя и профиль, обе формы должны:
from django.db import transaction
from django.http import HttpResponseRedirect
class RegistrationView(FormView):
template_name = "accounts/register.html"
form_class = UserRegistrationForm
profile_form_class = ProfileForm
success_url = reverse_lazy("accounts:register_done")
def get_profile_form(self):
return self.profile_form_class(
self.request.POST or None,
self.request.FILES or None,
)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context.setdefault("profile_form", self.get_profile_form())
return context
def post(self, request, *args, **kwargs):
user_form = self.get_form()
profile_form = self.get_profile_form()
if not (user_form.is_valid() and profile_form.is_valid()):
return self.render_to_response(self.get_context_data(
form=user_form,
profile_form=profile_form,
))
with transaction.atomic():
user = user_form.save()
profile = profile_form.save(commit=False)
profile.user = user
profile.save()
return HttpResponseRedirect(self.get_success_url())Если такая orchestration разрастается, отдельная view или сервис регистрации читается лучше, чем попытка заставить FormView изображать специализированный workflow.
YearArchiveView, MonthArchiveView и другие date-based generic views остаются актуальны. Они удобны для простого архива публикаций:
from django.views.generic.dates import MonthArchiveView
class PostMonthArchiveView(MonthArchiveView):
queryset = Post.objects.published()
date_field = "published_at"
month_format = "%m"
allow_future = False
make_object_list = TrueПроверьте timezone и индекс поля даты. Для сложной аналитики календарный generic view не заменяет отдельный отчёт.
ListView, DetailView, edit views и date views актуальны в Django 5.2/6.0. В legacy-коде можно встретить переопределение DeleteView.delete() для soft delete. После переработки DeleteView в Django 4.0 POST обрабатывается через FormMixin, поэтому такую логику переносят в form_valid().
Другие частые legacy-запахи — success_url = "/posts/", повторный запрос объекта в test_func() и две формы, где при ошибке теряется вторая. Исправляйте их с тестами GET, валидного POST, невалидного POST и доступа к чужому объекту.
Далее: URL Routing