Перейти к основному контенту
Tech Path Finder
КурсыИнтервьюКод-ревьюБлог
Tech Path Finder

Персонализированный путеводитель в IT. Квизы, мок-интервью, код ревью и аналитика прогресса.

@potapov_me

Платформа

  • Курсы
  • Прогресс
  • Мок-интервью
  • Код ревью
  • Живое ревью с ИИ
  • Тренажёр переговоров
  • Закладки

Контент

  • Блог
  • Главная
  • Обратная связь

Компания

  • О проекте
  • Тарифы
  • Условия использования
  • Конфиденциальность
  • Согласие на обработку данных
  • Cookie
  • Реквизиты

Аккаунт

  • Войти
  • Зарегистрироваться
  • Профиль

© 2026 Tech Path Finder. Все права защищены.

·ИП Потапов К.С.·Политика конфиденциальности·
Сделано с ❤️ в России
  1. Django Admin: Кастомизация
admin_custom

Django Admin: Кастомизация

ModelAdmin, inline, actions, custom pages, admin sites

Django Admin: рабочий внутренний интерфейс

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 = 50

list_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.PUBLISHED
class PostAdmin(admin.ModelAdmin): list_display = ["title", "is_published_display"]

@admin.display заменяет ручное присваивание short_description, admin_order_field и boolean. Имя в ordering должно соответствовать реальному полю или аннотации.

Следите за согласованностью: если модель имеет views, не добавляйте в list_display несуществующий view_count без метода с таким именем.

#Уберите N+1 из changelist

Столбцы с 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 также измеряют отдельно.

#Custom filter объявляется классом

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, а свой фильтр передают самим классом.

#Формы, readonly и связанные select

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.

#Inline — одна транзакция, но не бесплатный список

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().

#Ограничение объектов для staff

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.pk

obj=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 должны совпадать.

#Admin actions — массовый use case

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 — только для конкретной модели. Выберите область осознанно.

#Custom admin page

Собственный 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 повторно.

#Custom AdminSite

Отдельный 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.

#User и Group уже зарегистрированы

Стандартный django.contrib.auth регистрирует User и Group в default admin site. Повторный @admin.register(User) вызывает AlreadyRegistered. Для custom user зарегистрируйте свою модель с наследником django.contrib.auth.admin.UserAdmin, синхронизировав fieldsets и add_fieldsets с реальными полями.

#Тесты admin

Минимальный набор проверяет:

  • обычный пользователь не входит в admin;
  • staff без model permission не видит модель;
  • scoped staff не получает чужой объект по change URL;
  • related-field не позволяет подменить владельца;
  • action работает только на разрешённом queryset и сохраняет аудит;
  • changelist не возвращает N+1 после добавления вычисляемого столбца.

Admin — Python-код приложения, а не автоматически безопасная зона.

#Legacy и актуальность

short_description и admin_order_field продолжают встречаться в legacy и работают как атрибуты callable, но @admin.display читается лучше в современном коде. Переписывать их без причины не обязательно.

Опаснее старого синтаксиса — actions на queryset.update(), которые обходят новые инварианты, custom pages только со staff_member_required, N+1 в list_display и object scope только в интерфейсе. Эти места проверяют при каждом изменении доменной политики.

#Документация

  • The Django admin site
  • Admin actions
  • ModelAdmin methods

Далее: Middleware