Django-Admin.ru

Как устроен Django Admin: AdminSite, ModelAdmin и свои страницы

Как работает Django Admin изнутри: AdminSite и autodiscover, регистрация ModelAdmin, цикл changelist и changeform, хуки get_queryset и get_form, свои view.

Редакция Django-Admin.ru
· обновлено · 11 мин чтения

Содержание · 9 разделов
  1. Как админка подключается: autodiscover и admin.site
  2. admin.site.urls: какие маршруты создаёт админка
  3. Цикл запроса: от URL до HTML
  4. Страница списка: changelist_view
  5. Страница объекта: changeform_view
  6. Хуки ModelAdmin: что переопределять
  7. Свой AdminSite: несколько админок в проекте
  8. Как заменить сайт по умолчанию
  9. Свои страницы: get_urls() и admin_view()
  10. Страница уровня сайта
  11. Страница для конкретного объекта
  12. Частые ошибки
  13. Своя страница открывается без входа или падает с CSRF
  14. На своей странице нет шапки и меню
  15. Модель не появилась во второй админке
  16. Частые вопросы
  17. Как ограничить варианты в выпадающем списке внешнего ключа?
  18. Как запретить менять поле после создания объекта?
  19. Как сделать действие для одного объекта?
  20. Как проверить свою страницу админки в тестах?
  21. Когда делать страницу в админке, а когда обычное представление?
  22. Итог

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:indexAdminSite.index
admin/login/, admin/logout/admin:login, admin:logoutвход и выход
admin/library/admin:app_listAdminSite.app_index
admin/library/book/admin:library_book_changelistModelAdmin.changelist_view
admin/library/book/add/admin:library_book_addModelAdmin.add_view
admin/library/book/5/change/admin:library_book_changeModelAdmin.change_view
admin/library/book/5/delete/admin:library_book_deleteModelAdmin.delete_view
admin/library/book/5/history/admin:library_book_historyModelAdmin.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(). Обёртка делает три вещи:

  1. вызывает AdminSite.has_permission(request) — по умолчанию это is_active and is_staff; при отказе перенаправляет на страницу входа;
  2. добавляет защиту от CSRF (csrf_protect);
  3. запрещает кеширование ответа (never_cache), если не указано cacheable=True.

Дальше управление получает метод ModelAdmin.

Страница списка: changelist_view

  1. Проверка has_view_or_change_permission(request).
  2. Создание объекта ChangeList. Он берёт базовую выборку из get_queryset(request), применяет фильтры (get_list_filter), поиск (get_search_results), сортировку (get_ordering) и select_related (get_list_select_related), затем разбивает результат на страницы (get_paginator, list_per_page).
  3. Если пришёл POST с действием — вызывается response_action(), который передаёт отфильтрованный QuerySet в выбранный admin action.
  4. Если включён list_editable — строится и сохраняется формсет.
  5. Рендер шаблона admin/change_list.html с контекстом из admin_site.each_context(request).

Колонки берутся из get_list_display(request), а не напрямую из атрибута, — поэтому их можно менять в зависимости от пользователя.

Страница объекта: changeform_view

add_view и change_view — тонкие обёртки над changeform_view. Весь запрос выполняется внутри transaction.atomic(), поэтому при POST объект, инлайны и запись журнала сохраняются вместе или не сохраняются вовсе.

  1. get_object(request, object_id) — ищет объект через get_queryset(): если выборка ограничена, чужой объект просто не найдётся.
  2. Проверка прав: has_add_permission, has_change_permission или has_view_permission.
  3. get_fieldsets() → get_fields() → get_form() — какие поля показать и какой класс ModelForm построить. Сюда же влияет get_readonly_fields().
  4. При POST: form.is_valid() → save_form() → save_model() → save_related() (инлайны и M2M).
  5. Запись в журнал LogEntry через log_addition() или log_change().
  6. Ответ через 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
↑ ↓ выборEnter открытьEsc закрыть