ModelAdmin, inline, actions, custom pages, admin sites
Admin — интерфейс для доверенных сотрудников, построенный вокруг моделей и permissions. Он отлично подходит редакционным и операционным задачам, но не должен автоматически становиться кабинетом клиента: UX, object authorization и бизнес-процессы там придётся проектировать отдельно.
Материал актуален для Django 5.2 LTS и 6.0.
from django.contrib import admin
from .models import Post
@admin.register(Post)
class PostAdmin(admin.ModelAdmin):
list_display = ["title", "author", "status", "published_at"]
list_filter = ["status", "category"]
search_fields = ["title", "body", "author__username"]
date_hierarchy = "published_at"
list_per_page = 50list_display задаёт столбцы, list_filter — боковые фильтры, search_fields — поиск. Поиск по большим text-полям через icontains может быть дорогим; для крупной базы переопределяют get_search_results() и используют индексированный поиск выбранной СУБД.
Не дублируйте одно поле с префиксами ^, = и без префикса в одном search_fields: Django соединит варианты, но интерфейс не объяснит пользователю семантику. Выберите ожидаемый lookup.
@admin.display(
boolean=True,
ordering="status",
description="Опубликован",
)
def is_published_display(self, obj):
return obj.status == Post.Status.PUBLISHEDclass PostAdmin(admin.ModelAdmin):
list_display = ["title", "is_published_display"]@admin.display заменяет ручное присваивание short_description, admin_order_field и boolean. Имя в ordering должно соответствовать реальному полю или аннотации.
Следите за согласованностью: если модель имеет views, не добавляйте в list_display несуществующий view_count без метода с таким именем.
Столбцы с author, category и comments.count() способны создать запрос на каждую строку. Одиночные связи можно указать через list_select_related, агрегат — аннотировать:
from django.db.models import Count
class PostAdmin(admin.ModelAdmin):
list_display = ["title", "author", "category", "comment_count"]
list_select_related = ["author", "category"]
def get_queryset(self, request):
return super().get_queryset(request).annotate(
_comment_count=Count("comments", distinct=True),
)
@admin.display(ordering="_comment_count", description="Комментарии")
def comment_count(self, obj):
return obj._comment_countТот же подход нужен CategoryAdmin/TagAdmin с posts.count(). Не вызывайте related manager в каждой строке. Проверьте число SQL-запросов тестом changelist на нескольких объектах.
На очень большой таблице show_full_result_count = False убирает дополнительный полный count для отфильтрованного списка, но paginator всё ещё имеет собственные требования. Тяжёлые facets, date hierarchy и M2M filters также измеряют отдельно.
class PublicationStateFilter(admin.SimpleListFilter):
title = "состояние публикации"
parameter_name = "publication"
def lookups(self, request, model_admin):
return [
("ready", "Готово к публикации"),
("published", "Опубликовано"),
]
def queryset(self, request, queryset):
if self.value() == "ready":
return queryset.filter(status=Post.Status.PENDING)
if self.value() == "published":
return queryset.filter(status=Post.Status.PUBLISHED)
return queryset
class PostAdmin(admin.ModelAdmin):
list_filter = [PublicationStateFilter, "category"]Запись ("status", admin.SimpleListFilter) неверна: tuple-форма предназначена для field name вместе с подходящим FieldListFilter subclass, а свой фильтр передают самим классом.
class PostAdmin(admin.ModelAdmin):
fieldsets = [
("Текст", {"fields": ["title", "slug", "body"]}),
("Публикация", {"fields": ["status", "published_at"]}),
("Служебное", {
"fields": ["author", "created_at", "updated_at"],
"classes": ["collapse"],
}),
]
readonly_fields = ["created_at", "updated_at"]
autocomplete_fields = ["author", "category", "tags"]Для autocomplete_fields related ModelAdmin должен объявить search_fields, а пользователь — иметь нужные права просмотра. Autocomplete лучше огромного <select>, но endpoint всё равно должен возвращать только разрешённые related objects.
prepopulated_fields = {"slug": ("title",)} генерирует slug JavaScript-кодом при вводе. Это удобство интерфейса, не гарантия уникальности и не стратегия изменения публичного URL после публикации. Constraint и политика slug остаются в модели/use case.
class CommentInline(admin.TabularInline):
model = Comment
fields = ["author", "text", "is_approved", "created_at"]
readonly_fields = ["created_at"]
extra = 0
show_change_link = True
class PostAdmin(admin.ModelAdmin):
inlines = [CommentInline]TabularInline компактен, StackedInline показывает каждую форму блоком. Inline с сотнями строк неудобен и тяжёл; ограничьте редактирование, покажите ссылку на отдельный changelist или используйте pagination из стороннего решения после оценки.
Inline обязан соблюдать object permissions и доступные ForeignKey. Для чувствительных child objects переопределяют has_view_permission()/has_change_permission() inline и его get_queryset().
is_staff разрешает вход в admin, но не означает доступ ко всем моделям и строкам. ModelAdmin использует model permissions, а object scope добавляет проект:
class PostAdmin(admin.ModelAdmin):
def get_queryset(self, request):
queryset = super().get_queryset(request)
if request.user.is_superuser:
return queryset
return queryset.filter(author=request.user)
def has_view_permission(self, request, obj=None):
allowed = super().has_view_permission(request, obj)
return allowed and self._owns(request, obj)
def has_change_permission(self, request, obj=None):
allowed = super().has_change_permission(request, obj)
return allowed and self._owns(request, obj)
def has_delete_permission(self, request, obj=None):
allowed = super().has_delete_permission(request, obj)
return allowed and self._owns(request, obj)
def _owns(self, request, obj):
return obj is None or request.user.is_superuser or obj.author_id == request.user.pkobj=None означает проверку раздела/списка, а не конкретного объекта. Возврат True там не раскрывает строки, если get_queryset() уже ограничен.
Даже scoped queryset не мешает автору выбрать другого author, если поле редактируемо. Для обычного staff сделайте его readonly и принудительно сохраните владельца:
def get_readonly_fields(self, request, obj=None):
if request.user.is_superuser:
return ["created_at", "updated_at"]
return ["author", "created_at", "updated_at"]
def save_model(self, request, obj, form, change):
if not change and not request.user.is_superuser:
obj.author = request.user
super().save_model(request, obj, form, change)Если related field всё же доступно, ограничьте его queryset через formfield_for_foreignkey() либо custom form. UI и server-side enforcement должны совпадать.
from django.contrib import messages
from django.db import transaction
class PostAdmin(admin.ModelAdmin):
actions = ["publish_selected"]
@admin.action(description="Опубликовать выбранные", permissions=["change"])
def publish_selected(self, request, queryset):
published = 0
with transaction.atomic():
for post in queryset.select_for_update().order_by("pk"):
if post.can_publish_by(request.user):
post.publish(by=request.user)
published += 1
self.message_user(
request,
f"Опубликовано: {published}",
level=messages.SUCCESS,
)queryset.update() быстрее, но не вызывает save(), pre_save/post_save и не выполняет object-level метод. Используйте его только для простого массового присваивания, когда инварианты и аудит реализованы отдельно. Admin action не должна обходить тот же use case, который использует обычная view.
Для отправки тысяч писем action должна подтвердить выбор, создать одну batch/job запись и поставить фоновую задачу после commit. Цикл SMTP в HTTP-request зависнет, частично выполнится при ошибке и усложнит повтор.
admin.site.add_action() регистрирует глобальную action; метод в ModelAdmin.actions — только для конкретной модели. Выберите область осознанно.
Собственный URL должен идти перед стандартными patterns, быть обёрнут admin_view и отдельно проверить model permission:
from django.core.exceptions import PermissionDenied
from django.db.models import Count, Sum
from django.template.response import TemplateResponse
from django.urls import path
class PostAdmin(admin.ModelAdmin):
def get_urls(self):
custom_urls = [
path(
"stats/",
self.admin_site.admin_view(self.stats_view),
name="blog_post_stats",
),
]
return custom_urls + super().get_urls()
def stats_view(self, request):
if not self.has_view_permission(request):
raise PermissionDenied
scoped_posts = self.get_queryset(request)
totals = scoped_posts.aggregate(
posts=Count("pk"),
views=Sum("views", default=0),
)
context = {
**self.admin_site.each_context(request),
"opts": self.model._meta,
"title": "Статистика публикаций",
"totals": totals,
}
return TemplateResponse(
request,
"admin/blog/post/stats.html",
context,
)admin_site.admin_view() проверяет доступ в admin, применяет never_cache и сохраняет детали admin-site, но не знает, какое model permission требуется вашей странице. each_context() добавляет навигацию и site context. Статистика строится из того же scoped queryset, иначе обычный staff увидит агрегаты чужих данных.
Шаблон наследует admin/base_site.html и использует opts для breadcrumbs/links. Все POST-действия custom page защищаются CSRF и permission повторно.
Отдельный AdminSite полезен, если нужны другой набор моделей, URL и branding:
class OperationsAdminSite(admin.AdminSite):
site_header = "Operations"
site_title = "Operations admin"
index_title = "Рабочие инструменты"
operations_site = OperationsAdminSite(name="operations")
operations_site.register(Post, PostAdmin)urlpatterns = [
path("operations/", operations_site.urls),
]Не ослабляйте has_permission() ради удобства. Модельные и object-level проверки всё равно остаются в ModelAdmin.
Стандартный django.contrib.auth регистрирует User и Group в default admin site. Повторный @admin.register(User) вызывает AlreadyRegistered. Для custom user зарегистрируйте свою модель с наследником django.contrib.auth.admin.UserAdmin, синхронизировав fieldsets и add_fieldsets с реальными полями.
Минимальный набор проверяет:
Admin — Python-код приложения, а не автоматически безопасная зона.
short_description и admin_order_field продолжают встречаться в legacy и работают как атрибуты callable, но @admin.display читается лучше в современном коде. Переписывать их без причины не обязательно.
Опаснее старого синтаксиса — actions на queryset.update(), которые обходят новые инварианты, custom pages только со staff_member_required, N+1 в list_display и object scope только в интерфейсе. Эти места проверяют при каждом изменении доменной политики.
Далее: Middleware