Как устроен Django Admin: AdminSite, ModelAdmin и свои страницы
Как работает Django Admin изнутри: AdminSite и autodiscover, регистрация ModelAdmin, цикл changelist и changeform, хуки get_queryset и get_form, свои view.
Содержание · 9 разделов
- Как админка подключается: autodiscover и admin.site
- admin.site.urls: какие маршруты создаёт админка
- Цикл запроса: от URL до HTML
- Страница списка: changelist_view
- Страница объекта: changeform_view
- Хуки ModelAdmin: что переопределять
- Свой AdminSite: несколько админок в проекте
- Как заменить сайт по умолчанию
- Свои страницы: get_urls() и admin_view()
- Страница уровня сайта
- Страница для конкретного объекта
- Частые ошибки
- Своя страница открывается без входа или падает с CSRF
- На своей странице нет шапки и меню
- Модель не появилась во второй админке
- Частые вопросы
- Как ограничить варианты в выпадающем списке внешнего ключа?
- Как запретить менять поле после создания объекта?
- Как сделать действие для одного объекта?
- Как проверить свою страницу админки в тестах?
- Когда делать страницу в админке, а когда обычное представление?
- Итог
Django Admin состоит из двух классов. AdminSite — это реестр моделей и набор URL: главная страница, вход, выход и маршруты для каждой зарегистрированной модели. ModelAdmin — набор обычных class-based-представлений для одной модели: список, добавление, изменение, удаление и история. Всё остальное — опции и методы-хуки, которые эти представления вызывают по ходу запроса.
Понимая этот цикл, вы точно знаете, какой метод переопределить: чтобы ограничить выборку, поменять форму, завести вторую админку или добавить свою страницу. Примеры проверены на Django 5.2 LTS и 6.x. Базовая настройка админки — в полном руководстве по Django Admin.
Как админка подключается: autodiscover и admin.site
Когда в INSTALLED_APPS есть "django.contrib.admin", Django загружает конфигурацию приложения AdminConfig. В её методе ready() вызывается admin.autodiscover() — он импортирует модуль admin.py каждого установленного приложения. Именно поэтому код регистрации в admin.py выполняется, хотя вы его нигде не импортируете.
# library/admin.py
from django.contrib import admin
from .models import Book
@admin.register(Book) # то же, что admin.site.register(Book, BookAdmin)
class BookAdmin(admin.ModelAdmin):
list_display = ["title", "author", "published"]
admin.site — экземпляр сайта по умолчанию (AdminSite с именем "admin"). Метод register() создаёт один экземпляр BookAdmin на всё время работы процесса и кладёт его в словарь site._registry вида «модель → экземпляр ModelAdmin». Отсюда важное следствие: не храните в атрибутах ModelAdmin данные конкретного запроса — экземпляр общий для всех пользователей.
Повторная регистрация той же модели вызывает AlreadyRegistered. Чтобы заменить чужой ModelAdmin (например, для User), сначала снимите регистрацию: admin.site.unregister(User).
Совет: если автопоиск
admin.pyне нужен, подключите"django.contrib.admin.apps.SimpleAdminConfig"вместо"django.contrib.admin"и регистрируйте модели вручную.
admin.site.urls: какие маршруты создаёт админка
В корневом urls.py админка подключается одной строкой:
path("admin/", admin.site.urls)
Свойство urls возвращает кортеж (get_urls(), "admin", имя_сайта): список маршрутов, имя приложения admin и пространство имён экземпляра. AdminSite.get_urls() собирает общие страницы сайта и для каждой модели из реестра подключает маршруты её ModelAdmin:
| URL | Имя маршрута | Представление |
|---|---|---|
admin/ | admin:index | AdminSite.index |
admin/login/, admin/logout/ | admin:login, admin:logout | вход и выход |
admin/library/ | admin:app_list | AdminSite.app_index |
admin/library/book/ | admin:library_book_changelist | ModelAdmin.changelist_view |
admin/library/book/add/ | admin:library_book_add | ModelAdmin.add_view |
admin/library/book/5/change/ | admin:library_book_change | ModelAdmin.change_view |
admin/library/book/5/delete/ | admin:library_book_delete | ModelAdmin.delete_view |
admin/library/book/5/history/ | admin:library_book_history | ModelAdmin.history_view |
Имена маршрутов строятся по схеме admin:<app_label>_<model_name>_<страница>, и ссылки на админку правильно строить через них:
from django.urls import reverse
reverse("admin:library_book_change", args=[5])
# → '/admin/library/book/5/change/'
В шаблоне то же самое: {% url 'admin:library_book_changelist' %}. Так ссылки не сломаются, даже если вы смените путь админки (см. защиту Admin URL).
Цикл запроса: от URL до HTML
Каждое представление админки обёрнуто в AdminSite.admin_view(). Обёртка делает три вещи:
- вызывает
AdminSite.has_permission(request)— по умолчанию этоis_active and is_staff; при отказе перенаправляет на страницу входа; - добавляет защиту от CSRF (
csrf_protect); - запрещает кеширование ответа (
never_cache), если не указаноcacheable=True.
Дальше управление получает метод ModelAdmin.
Страница списка: changelist_view
- Проверка
has_view_or_change_permission(request). - Создание объекта
ChangeList. Он берёт базовую выборку изget_queryset(request), применяет фильтры (get_list_filter), поиск (get_search_results), сортировку (get_ordering) иselect_related(get_list_select_related), затем разбивает результат на страницы (get_paginator,list_per_page). - Если пришёл POST с действием — вызывается
response_action(), который передаёт отфильтрованныйQuerySetв выбранный admin action. - Если включён
list_editable— строится и сохраняется формсет. - Рендер шаблона
admin/change_list.htmlс контекстом изadmin_site.each_context(request).
Колонки берутся из get_list_display(request), а не напрямую из атрибута, — поэтому их можно менять в зависимости от пользователя.
Страница объекта: changeform_view
add_view и change_view — тонкие обёртки над changeform_view. Весь запрос выполняется внутри transaction.atomic(), поэтому при POST объект, инлайны и запись журнала сохраняются вместе или не сохраняются вовсе.
get_object(request, object_id)— ищет объект черезget_queryset(): если выборка ограничена, чужой объект просто не найдётся.- Проверка прав:
has_add_permission,has_change_permissionилиhas_view_permission. get_fieldsets()→get_fields()→get_form()— какие поля показать и какой классModelFormпостроить. Сюда же влияетget_readonly_fields().- При POST:
form.is_valid()→save_form()→save_model()→save_related()(инлайны и M2M). - Запись в журнал
LogEntryчерезlog_addition()илиlog_change(). - Ответ через
response_add()илиresponse_change()— редирект в зависимости от нажатой кнопки «Сохранить», «Сохранить и продолжить» и т. п.
Важно: переопределяя
save_model(), всегда вызывайтеsuper().save_model(request, obj, form, change)— иначе объект не будет сохранён.
Хуки ModelAdmin: что переопределять
| Метод | Когда вызывается | Типичное применение |
|---|---|---|
get_queryset(request) | Список, поиск объекта, действия | select_related, annotate, ограничение по владельцу |
get_list_display(request) | Список | Колонки по ролям |
get_search_results(request, qs, term) | Поиск в списке | Свой алгоритм поиска |
get_fields / get_fieldsets | Форма объекта | Скрыть поля от части пользователей |
get_readonly_fields(request, obj) | Форма объекта | Запретить менять поле после создания |
get_form(request, obj, change) | Форма объекта | Подменить класс формы, поменять виджеты |
get_inlines(request, obj) | Форма объекта | Инлайны только при редактировании |
save_model / delete_model | Сохранение и удаление | Заполнить автора, отправить событие |
has_*_permission(request, obj) | Везде | Объектные права |
Пример: ограничим выборку, скроем поле от обычных сотрудников и поменяем подсказку в форме.
from django.contrib import admin
from .models import Order
@admin.register(Order)
class OrderAdmin(admin.ModelAdmin):
list_display = ["number", "status"]
readonly_fields = ["total"]
def get_queryset(self, request):
# Архивные заказы не видны ни в списке, ни по прямой ссылке
return super().get_queryset(request).exclude(status=Order.Status.ARCHIVED)
def get_fields(self, request, obj=None):
fields = super().get_fields(request, obj)
if not request.user.is_superuser:
fields = [f for f in fields if f != "discount"]
return fields
def get_form(self, request, obj=None, change=False, **kwargs):
form = super().get_form(request, obj, change, **kwargs)
if "number" in form.base_fields:
form.base_fields["number"].help_text = "Номер из 1С"
return form
Ограничение в get_queryset() срабатывает и в списке, и в форме объекта, и в действиях — потому что все они получают выборку через этот метод. Там же стоит бороться с лишними запросами: о select_related и prefetch_related в админке — в разборе проблемы N+1.
Свой AdminSite: несколько админок в проекте
Иногда нужна отдельная панель — например, для операторов поддержки с урезанным набором моделей и своим заголовком. Создайте подкласс AdminSite:
# shop/sites.py
from django.contrib import admin
class SupportAdminSite(admin.AdminSite):
site_header = "Поддержка"
site_title = "Поддержка"
index_title = "Рабочее место оператора"
def has_permission(self, request):
# Только сотрудники из группы «Поддержка»
return (
super().has_permission(request)
and request.user.groups.filter(name="Поддержка").exists()
)
support_site = SupportAdminSite(name="support")
Зарегистрируйте в нём модели (можно с другим ModelAdmin, чем в основной админке) и подключите его URL:
# shop/admin.py
from .sites import support_site
support_site.register(Order, OrderAdmin)
# config/urls.py
from django.contrib import admin
from django.urls import path
from shop.sites import support_site
urlpatterns = [
path("admin/", admin.site.urls),
path("support/", support_site.urls),
]
Аргумент name задаёт пространство имён маршрутов: ссылки на вторую админку строятся как reverse("support:shop_order_changelist"). Декоратор @admin.register(Order) без аргумента site регистрирует модель только в admin.site, поэтому в своём сайте регистрируйте явно: support_site.register(...) или @admin.register(Order, site=support_site).
Как заменить сайт по умолчанию
Если нужно изменить поведение именно admin.site (и всех сторонних приложений, которые в нём регистрируются), укажите свой класс через AppConfig:
# config/apps.py
from django.contrib.admin.apps import AdminConfig
class MyAdminConfig(AdminConfig):
default_site = "config.admin.MyAdminSite"
# settings.py
INSTALLED_APPS = [
"config.apps.MyAdminConfig", # вместо "django.contrib.admin"
# ...
]
Для смены одних только заголовков это избыточно — достаточно admin.site.site_header = "...". Полная переделка внешнего вида — в статье про темизацию Django Admin.
Свои страницы: get_urls() и admin_view()
Страница уровня сайта
Переопределите get_urls() у своего AdminSite и оберните представление в admin_view() — тогда оно получит ту же проверку прав, CSRF и запрет кеширования:
from django.contrib import admin
from django.template.response import TemplateResponse
from django.urls import path
from .models import Order
class SupportAdminSite(admin.AdminSite):
def get_urls(self):
urls = super().get_urls()
custom = [
path("stats/", self.admin_view(self.stats_view), name="stats"),
]
return custom + urls # свои маршруты — первыми
def stats_view(self, request):
context = {
**self.each_context(request), # шапка, меню, тема админки
"title": "Статистика",
"orders_total": Order.objects.count(),
}
return TemplateResponse(request, "admin/shop/stats.html", context)
Шаблон наследует оформление админки:
{# templates/admin/shop/stats.html #}
{% extends "admin/base_site.html" %}
{% block content %}
<p>Всего заказов: {{ orders_total }}</p>
{% endblock %}
Свои маршруты ставятся перед стандартными: в конце списка AdminSite стоит маршрут-«ловушка» для несуществующих адресов, и всё, что добавлено после него, не сработает. Для отчётов и графиков на таких страницах пригодятся статьи про виджеты дашборда и визуализацию данных в админке.
Страница для конкретного объекта
Свою страницу можно добавить и в ModelAdmin — например, «Отправить письмо повторно» для заказа:
from django.core.exceptions import PermissionDenied
from django.http import HttpResponseNotAllowed
from django.shortcuts import get_object_or_404, redirect
from django.urls import path
class OrderAdmin(admin.ModelAdmin):
def get_urls(self):
info = self.opts.app_label, self.opts.model_name
custom = [
path(
"<path:object_id>/resend/",
self.admin_site.admin_view(self.resend_view),
name="%s_%s_resend" % info,
),
]
return custom + super().get_urls()
def resend_view(self, request, object_id):
if request.method != "POST":
return HttpResponseNotAllowed(["POST"])
order = get_object_or_404(self.get_queryset(request), pk=object_id)
if not self.has_change_permission(request, order):
raise PermissionDenied
# ... отправка письма ...
self.message_user(request, f"Письмо по заказу {order} отправлено повторно")
return redirect(f"{self.admin_site.name}:shop_order_change", order.pk)
Порядок здесь тоже критичен: стандартный маршрут <path:object_id>/ перехватит 5/resend/ и перенаправит на страницу изменения, если ваш маршрут окажется после него. Обратите внимание и на то, что объект ищется через get_queryset(), а права проверяются явно — admin_view() проверяет только доступ к админке в целом, но не права на конкретную модель.
Представление меняет состояние, поэтому принимает только POST: GET-запрос браузер отправит и по ссылке из чужого письма, а CSRF-защита GET не проверяет. Кнопку для такой страницы делают формой в блоке object-tools-items — он выводится над основной формой объекта:
{# templates/admin/shop/order/change_form.html #}
{% extends "admin/change_form.html" %}
{% block object-tools-items %}
<li>
<form method="post" action="{% url 'admin:shop_order_resend' original.pk %}">
{% csrf_token %}
<button type="submit">Отправить письмо повторно</button>
</form>
</li>
{{ block.super }}
{% endblock %}
Частые ошибки
Своя страница открывается без входа или падает с CSRF
Представление подключено без admin_view(). Обёртка отвечает и за проверку is_staff, и за CSRF-защиту.
На своей странице нет шапки и меню
В контекст не передан **self.admin_site.each_context(request) (или self.each_context(request) внутри AdminSite). Без него шаблон admin/base_site.html не знает заголовков, списка приложений и настроек темы.
Модель не появилась во второй админке
admin.site.register() и @admin.register() без аргумента site добавляют модель только в сайт по умолчанию. Для своего сайта вызовите support_site.register(...) явно — и убедитесь, что модуль с регистрацией импортируется (проще всего держать её в admin.py приложения, который загружает автопоиск).
Частые вопросы
Как ограничить варианты в выпадающем списке внешнего ключа?
get_queryset() влияет на список объектов модели, но не на варианты в полях формы. Для них переопределите formfield_for_foreignkey() (для связей «многие ко многим» — formfield_for_manytomany()):
@admin.register(Order)
class OrderAdmin(admin.ModelAdmin):
def formfield_for_foreignkey(self, db_field, request, **kwargs):
if db_field.name == "manager" and not request.user.is_superuser:
kwargs["queryset"] = db_field.related_model.objects.filter(pk=request.user.pk)
return super().formfield_for_foreignkey(db_field, request, **kwargs)
Как запретить менять поле после создания объекта?
Верните его из get_readonly_fields() только для существующего объекта — при добавлении obj равен None:
def get_readonly_fields(self, request, obj=None):
readonly = list(super().get_readonly_fields(request, obj))
if obj is not None:
readonly.append("number")
return readonly
Как сделать действие для одного объекта?
В Django 6.1 admin action можно вывести на странице объекта параметром location=ActionLocation.CHANGE_FORM декоратора @admin.action — своя страница через get_urls() для этого не нужна. Пример — в статье об admin actions для больших таблиц. В более ранних версиях подходит представление с кнопкой из раздела выше.
Как проверить свою страницу админки в тестах?
Авторизуйте тестовый клиент сотрудником и запросите страницу по имени маршрута. Для второй админки пространство имён — её name:
from django.contrib.auth import get_user_model
from django.test import TestCase
from django.urls import reverse
class SupportSiteTests(TestCase):
def test_stats_page(self):
user = get_user_model().objects.create_user("op", password="x", is_staff=True)
user.groups.create(name="Поддержка")
self.client.force_login(user)
response = self.client.get(reverse("support:stats"))
self.assertEqual(response.status_code, 200)
Когда делать страницу в админке, а когда обычное представление?
В админку выносят инструменты для сотрудников: им уже выданы права, доступны шапка, меню и журнал действий. Страницы для клиентов сайта и всё, что требует своего дизайна и логики входа, делают обычными представлениями и шаблонами Django.
Итог
AdminSite отвечает за реестр, URL и общий доступ, ModelAdmin — за представления одной модели, а поведение настраивается хуками get_*, save_* и has_*_permission, которые вызываются в предсказуемом порядке. Свои страницы добавляйте через get_urls() с admin_view() и ставьте их перед стандартными маршрутами. Следующий шаг — массовые действия на больших таблицах; все материалы кластера собраны в разделе Django Admin.
- #Django Admin
- #AdminSite
- #ModelAdmin
- #get_urls
- #Django Internals