web24.team

Админка Django: полный справочник по настройке и кастомизации

Разбор возможностей Django admin в версиях 5.2–6.1: списки и фильтры, поиск, инлайны, экшены, права доступа, переопределение шаблонов, свои страницы и кастомные кверисеты.

Встроенная админка Django — это полноценная панель управления проектом, которой при должной настройке могут пользоваться контент-менеджеры, поддержка, менеджеры и другие сотрудники. Проблема в том, что возможностей у неё очень много, а документация хоть и считается образцовой для подобного рода проектов, но требует слишком много времени на изучение всех возможностей, а половина статей в интернете описывает Django 2.х. Ниже — структурированный справочник по тому, что умеет django.contrib.admin в актуальных версиях 5.2, 6.0 и 6.1: списки, поиск, фильтры, инлайны, экшены, права, шаблоны, свои страницы и кастомные кверисеты. Мы также предупредим вас там, где переход на django-unfold ломает часть стандартных механизмов.

Какие версии Django актуальны и что изменилось в админке

На сентябрь 2026 актуальны три ветки: Django 6.1 (вышла 5 августа 2026), Django 6.0 (3 декабря 2025) и Django 5.2 LTS (2 апреля 2025, поддержка до апреля 2028). Django 4.2 LTS закончил жизнь 7 апреля 2026 — если проект всё ещё на нём, обновление это уже вопрос безопасности, а не удобства. Ветки 6.x требуют Python 3.12–3.14.

Что изменилось именно в админке за последние релизы:

Версия Изменение в админке
5.0 Фасетные счётчики в фильтрах (show_facets), AdminSite.get_log_entries(), AdminSite.get_model_admin(), поддержка boolean у свойств в list_display
5.1 list_display понимает __-лукапы по связанным моделям, collapse-секции переехали на details/summary, фильтры рендерятся в nav
5.2 Блок extrabody в admin/base.html для своих скриптов, URLField в списке рендерится ссылкой
6.0 Иконки Font Awesome 6.7.2, AdminSite.password_change_form, разное оформление сообщений DEBUG и INFO
6.1 Экшены в форме объекта (location), description_plural, delete_confirmation_max_display, оптимизация list_select_related, поле под лейблом в форме, optgroup в FilteredSelectMultiple

Два момента из 6.1 стоит запомнить отдельно: оба ломают обратную совместимость и оба не заметны сразу. Во-первых, CSS-класс wide в fieldsets удалён — если вы им пользовались, он просто перестанет что-либо делать. Во-вторых, list_select_related = True объявлен устаревшим: теперь нужно перечислять конкретные поля, а значение по умолчанию False стало умнее и подтягивает только те внешние ключи, которые реально есть в list_display.

Как настроить список объектов: колонки, свойства и вычисляемые поля

Колонки списка задаёт list_display, и туда можно положить четыре вида значений: поле модели, поле связанной модели через __, свойство модели и метод ModelAdmin.

Самый частый реальный запрос — показать в списке заказов не «Order object (17)», а номер, клиента и сумму:

@admin.register(Order)
class OrderAdmin(admin.ModelAdmin):
    list_display = ["number", "customer__email", "total", "status_badge", "is_paid"]
    list_display_links = ["number"]
    empty_value_display = "—"

Запись customer__email работает начиная с Django 5.1 — до этого приходилось писать метод-обёртку. Обратите внимание, что по такой колонке админка умеет сортировать, а SQL-запрос получает нужный JOIN автоматически.

Если нужного значения в модели нет, колонку можно собрать самому — обычным методом в классе админки. Он получает объект и возвращает что угодно: строку, число, готовый HTML. Декоратор @admin.display при этом описывает, как показать результат:

@admin.display(description="Статус", ordering="status")
def status_badge(self, obj):
    colors = {"paid": "#16a34a", "new": "#64748b", "cancelled": "#dc2626"}
    return format_html(
        '<span style="color:{}">●</span> {}',
        colors.get(obj.status, "#64748b"),
        obj.get_status_display(),
    )

format_html здесь обязателен: он экранирует подставляемые значения, а строка, собранная через f-string, будет выведена как текст с видимыми тегами. Если строка собирается из нескольких кусков — format_html_join и mark_safe из django.utils.html, но mark_safe только для данных, которые вы контролируете.

Параметры @admin.display:

Параметр Что делает
description Заголовок колонки вместо имени метода
ordering Поле или выражение ORM, по которому сортировать колонку
boolean=True Рисует зелёную галочку или красный крестик вместо True/False
empty_value Что показать вместо пустого значения именно в этой колонке

Картинки выводятся тем же способом — методом, который возвращает тег img:

@admin.display(description="Фото")
def preview(self, obj):
    if not obj.image:
        return "—"
    return format_html('<img src="{}" style="height:40px;border-radius:4px">', obj.image.url)

Свойства модели (@property) в list_display тоже работают и с Django 5.0 поддерживают атрибут boolean, но сортировать по ним нельзя — в базе такого столбца нет. Если сортировка нужна, значение надо считать в get_queryset через annotate (об этом ниже, в разделе про кверисеты).

Сортировкой управляют три настройки: ordering задаёт порядок по умолчанию, admin_order_field (или параметр ordering в @admin.display) включает сортировку для вычисляемой колонки, а sortable_by наоборот отключает сортировку для перечисленных колонок. Пустой список в sortable_by отключает сортировку полностью — иногда это нужно, чтобы пользователи не роняли базу сортировкой по неиндексированному полю.

Как настроить поиск в списке админки Django

Поисковая строка появляется, как только вы задали search_fields. Django разбивает запрос на слова и ищет объекты, в которых каждое слово встретилось хотя бы в одном из полей; фразу в кавычках ("иван петров") ищет целиком.

search_fields = ["number", "customer__email", "customer__phone", "items__sku"]
search_help_text = "Номер заказа, email, телефон клиента или артикул товара"

По умолчанию используется icontains. Изменить лукап можно двумя способами — полной записью (first_name__exact) или коротким префиксом:

Префикс Лукап Когда применять
без префикса icontains Обычный поиск по подстроке
^ istartswith Поиск по началу строки, использует индекс
= iexact Точное совпадение: артикул, номер заказа, код
@ search Полнотекстовый поиск (MySQL, либо SearchVectorField в PostgreSQL)

В Django 6.1 поиск поумнел в отношении нечисловых полей: теперь можно писать search_fields = ["number", "customer__email", "total__exact"], и поисковый термин, который не приводится к нужному типу, просто пропускается для этого поля вместо падения или кастинга в текст. Раньше запрос иван 1500 по такой конфигурации либо ломался, либо превращал сумму в строку.

Поиск по связям через __ порождает JOIN и дубликаты строк — Django добавляет distinct(), но на больших таблицах это дорого. Практическое правило: поиск по items__sku уместен при десятках тысяч заказов, а при миллионах лучше вынести его в отдельный индекс.

Свою логику поиска подключают через get_search_results. Типичная задача — «если ввели число, ищем по ID, иначе по тексту»:

def get_search_results(self, request, queryset, search_term):
    qs, use_distinct = super().get_search_results(request, queryset, search_term)
    if search_term.isdigit():
        qs |= self.model.objects.filter(pk=int(search_term))
    return qs, use_distinct

Полнотекстовый поиск в PostgreSQL подключается там же, через SearchVector и SearchRank из django.contrib.postgres.search:

def get_search_results(self, request, queryset, search_term):
    if not search_term:
        return queryset, False
    query = SearchQuery(search_term, config="russian")
    qs = queryset.annotate(rank=SearchRank(F("search_vector"), query)).filter(rank__gt=0.01)
    return qs.order_by("-rank"), False

Держать search_vector в SearchVectorField с GIN-индексом и обновлять триггером — единственный способ получить приемлемую скорость на сотнях тысяч записей. Интерактивный поиск с подсказками в выпадающем списке — это уже не search_fields, а autocomplete_fields; о нём в разделе про связи.

Как настроить фильтры в админке Django и написать свой

list_filter принимает имена полей, пути по связям и классы фильтров. Django сам подбирает тип фильтра под поле, но можно указать его явно.

list_filter = [
    "status",
    "created_at",
    ("customer__city", admin.RelatedOnlyFieldListFilter),
    ("comment", admin.EmptyFieldListFilter),
    ("is_paid", admin.BooleanFieldListFilter),
    HasDiscountFilter,
]

Готовые классы фильтров, которые надо подключать явно:

Класс Что делает
RelatedFieldListFilter Все объекты связанной модели в списке значений
RelatedOnlyFieldListFilter Только те связанные объекты, которые реально встречаются в выборке
BooleanFieldListFilter Да/Нет/Неизвестно для булевых и nullable-полей
ChoicesFieldListFilter Значения из choices поля
AllValuesFieldListFilter Все различные значения столбца, включая те, у которых нет choices
EmptyFieldListFilter Пусто / не пусто, учитывает и NULL, и пустую строку
DateFieldListFilter Сегодня, последние 7 дней, этот месяц, этот год

Свой фильтр — это подкласс SimpleListFilter с двумя методами. Реальный пример: показать заказы, в которых применялась скидка, при том что отдельного поля в модели нет.

class HasDiscountFilter(admin.SimpleListFilter):
    title = "Скидка"
    parameter_name = "has_discount"

    def lookups(self, request, model_admin):
        return [("yes", "Со скидкой"), ("no", "Без скидки")]

    def queryset(self, request, queryset):
        if self.value() == "yes":
            return queryset.filter(discount__gt=0)
        if self.value() == "no":
            return queryset.filter(discount=0)
        return queryset

title — подпись фильтра, parameter_name — имя GET-параметра, lookups возвращает пары «значение, подпись», queryset фильтрует выборку. Метод lookups может быть генератором и строить список по данным — например, показывать только те города, где есть заказы.

Фасетные счётчики появились в Django 5.0: рядом с каждым значением фильтра показывается количество записей. По умолчанию их включает сам пользователь кнопкой, но поведение настраивается:

from django.contrib.admin import ShowFacets

class OrderAdmin(admin.ModelAdmin):
    show_facets = ShowFacets.ALWAYS   # или NEVER, или ALLOW (по умолчанию)

Считаются фасеты отдельным агрегирующим запросом, поэтому ALWAYS на таблице в миллионы строк — верный способ получить тормозящий список.

Фильтр по нескольким значениям сразу делается через лукап __in и атрибут list_separator: наследуетесь от FieldListFilter, задаёте lookup_kwarg = "%s__in" % field_path и возвращаете его из expected_parameters. Если писать такое вручную не хочется, для диапазонов дат и чисел есть живой пакет django-admin-rangefilter (версия 0.15.0 от 28 августа 2026) — он добавляет DateRangeFilter, DateTimeRangeFilter и NumericRangeFilter с готовыми виджетами.

Если вы фильтруете по полю связанной модели, которого нет в list_filter напрямую, Django может выдать DisallowedModelAdminLookup — за это отвечает метод lookup_allowed, и его можно переопределить, чтобы разрешить конкретный путь.

Пагинация, иерархия по датам и редактирование прямо в списке

За постраничную навигацию отвечают три настройки, и все три влияют на скорость.

Настройка По умолчанию Смысл
list_per_page 100 Сколько объектов на странице
list_max_show_all 200 До какого количества показывать ссылку «Показать все»
show_full_result_count True Считать ли полное число записей в таблице (99 результатов (103 всего))
paginator Paginator Свой класс пагинатора

Первое, что стоит сделать на большой таблице — выключить show_full_result_count. Он выполняет COUNT(*) по всей таблице на каждой отфильтрованной странице, и на десятке миллионов строк в PostgreSQL это секунды. Второй приём — свой пагинатор с приблизительным счётчиком:

class FastPaginator(Paginator):
    @cached_property
    def count(self):
        with connection.cursor() as cursor:
            cursor.execute("SELECT reltuples::bigint FROM pg_class WHERE relname = %s",
                           [self.object_list.model._meta.db_table])
            return cursor.fetchone()[0]

Точность приблизительная, зато страница открывается мгновенно. Подключается одной строкой: paginator = FastPaginator.

Иерархическая навигация по датам включается атрибутом date_hierarchy и рисует над списком строку «2026 → Сентябрь → 13». Работает только с одним полем даты или датой по связи (date_hierarchy = "created_at" или "order__created_at"). Учтите, что на каждый уровень она делает свой агрегирующий запрос, поэтому поле обязано быть проиндексировано.

Быстрое редактирование в спискеlist_editable. Поля из этого списка рендерятся как формы прямо в таблице, внизу появляется кнопка «Сохранить»:

list_display = ["number", "status", "is_paid", "comment"]
list_display_links = ["number"]
list_editable = ["status", "is_paid"]

Главное ограничение: поле не может одновременно быть в list_editable и list_display_links, иначе Django упадёт с ошибкой на старте — поэтому в list_display всегда должна быть хотя бы одна колонка-ссылка. Второе ограничение: массовое сохранение идёт в одной транзакции и вызывает save() для каждого изменённого объекта, так что редактировать таким образом сотню строк с тяжёлыми сигналами — плохая идея.

Как работает CRUD в админке: регистрация моделей и валидация данных

Весь интерфейс создания, чтения, изменения и удаления админка генерирует из модели автоматически: типы полей превращаются в виджеты формы, verbose_name — в подписи, choices — в выпадающие списки, help_text — в подсказки. Разработчику остаётся только зарегистрировать модель.

Регистрация делается в admin.py приложения, которое Django находит сам через admin.autodiscover():

@admin.register(Order, OrderItem)     # можно несколько моделей сразу
class OrderAdmin(admin.ModelAdmin):
    ...

admin.site.register(Product)          # то же самое без класса настроек
admin.site.unregister(Group)          # снять регистрацию чужой модели

Декоратор @admin.register принимает ещё и site= — это нужно, когда в проекте несколько админ-сайтов.

Валидация в админке многослойная, и знать порядок полезно, потому что ошибки в разных слоях выглядят по-разному:

  1. Валидаторы поля моделиvalidators=[...], max_length, choices, null/blank.
  2. Model.clean_fields() — приведение типов и проверка каждого поля по отдельности.
  3. ModelForm.clean_<имя_поля>() — ваша проверка одного поля, результат надо вернуть.
  4. Model.clean() и ModelForm.clean() — проверки, которым нужны несколько полей сразу.
  5. Model.validate_unique() и validate_constraints()unique_together и CheckConstraint/UniqueConstraint из Meta; начиная с Django 4.1 ограничения БД проверяются формой, а не падают ошибкой базы.

Проверка, которая зависит от двух полей, живёт в форме:

class OrderAdminForm(forms.ModelForm):
    def clean(self):
        cleaned = super().clean()
        if cleaned.get("status") == "shipped" and not cleaned.get("tracking_number"):
            raise ValidationError({"tracking_number": "Для отправленного заказа нужен трек-номер"})
        return cleaned

class OrderAdmin(admin.ModelAdmin):
    form = OrderAdminForm

Передача словаря в ValidationError привязывает сообщение к конкретному полю, а не к форме целиком — админка подсветит именно его. Валидацию инлайнов делают в BaseInlineFormSet.clean(), который подключается через InlineModelAdmin.formset.

История изменений: встроенный LogEntry и django-simple-history

Из коробки Django пишет журнал в модель django.contrib.admin.models.LogEntry: кто, когда, какой объект добавил, изменил или удалил и какие поля затронул. Журнал виден по кнопке «История» на странице объекта и на главной странице админки в блоке последних действий.

Ограничения встроенного журнала надо понимать сразу: он фиксирует только действия через админку, хранит имена изменённых полей, но не хранит старые и новые значения, и не умеет откатывать изменения. Управлять им можно методами log_addition, log_change, log_deletions у ModelAdmin и AdminSite.get_log_entries() (Django 5.0) — например, чтобы показывать на главной только действия текущего пользователя.

Когда нужен полноценный аудит с диффами и откатом, ставят django-simple-history. Актуальная версия 3.13.0 (22 июля 2026) поддерживает Django 5.2, 6.0 и 6.1 и Python 3.10–3.14. Пакет создаёт для модели «теневую» таблицу и пишет в неё снимок записи на каждое сохранение — не только из админки, но и из скриптов, API и команд.

Подключение занимает три строки в модели и одну в админке:

class Order(models.Model):
    status = models.CharField(max_length=20, choices=STATUSES)
    total = models.DecimalField(max_digits=10, decimal_places=2)
    history = HistoricalRecords(excluded_fields=["updated_at"])

@admin.register(Order)
class OrderAdmin(SimpleHistoryAdmin):
    history_list_display = ["status", "total"]

SimpleHistoryAdmin заменяет стандартную страницу истории: показывает список версий с автором и датой, добавляет колонки из history_list_display и даёт кнопку «Вернуть» на любой версии. Чтобы автор записывался не только для админки, но и для остальных частей приложения, в MIDDLEWARE добавляют simple_history.middleware.HistoryRequestMiddleware.

Что даёт пакет за пределами админки:

Возможность Как выглядит в коде
Состояние объекта на дату Order.history.as_of(datetime(2026, 8, 1))
Последняя версия order.history.most_recent()
Соседние версии record.prev_record, record.next_record
Дифф двух версий new.diff_against(old)delta.changes с field, old, new и delta.changed_fields
Причина изменения update_change_reason(order, "Возврат по заявке 4412")
Массовое создание с историей bulk_create_with_history(objs, Order)
Заполнить историю для существующих данных manage.py populate_history --auto
Почистить дубликаты и старые записи manage.py clean_duplicate_history, clean_old_history

Две настройки, которые почти всегда нужны в проде: SIMPLE_HISTORY_REVERT_DISABLED = True убирает кнопку отката, если возвращать версии должны только избранные, а SIMPLE_HISTORY_ENFORCE_HISTORY_MODEL_PERMISSIONS = True заставляет проверять права на историческую модель отдельно от основной — так менеджер может редактировать заказы, но не видеть журнал. Про расход места тоже стоит помнить: таблица истории растёт быстрее основной, и excluded_fields для служебных полей вроде updated_at экономит заметную долю строк.

Для Unfold есть готовая интеграция — модуль unfold.contrib.simple_history в INSTALLED_APPS, иначе страница истории будет выглядеть как кусок старой админки внутри новой.

Мультисайтинг: одна админка на несколько сайтов

Здесь есть два разных сценария, и их часто путают.

Несколько сайтов в одной базе — это django.contrib.sites. Модель получает FK на Site, а админка фильтрует выборку по текущему сайту:

class Article(models.Model):
    site = models.ForeignKey(Site, on_delete=models.CASCADE)
    objects = models.Manager()
    on_site = CurrentSiteManager()

class ArticleAdmin(admin.ModelAdmin):
    def get_queryset(self, request):
        qs = super().get_queryset(request)
        return qs if request.user.is_superuser else qs.filter(site__in=request.user.sites.all())

Несколько независимых админок — это несколько экземпляров AdminSite. Классический случай: внутренняя админка для сотрудников на /admin/ и урезанная для партнёров на /partners/, с разным набором моделей, своим логотипом и своей проверкой доступа:

class PartnerAdminSite(admin.AdminSite):
    site_header = "Кабинет партнёра"

    def has_permission(self, request):
        return request.user.is_active and request.user.groups.filter(name="partners").exists()

partner_site = PartnerAdminSite(name="partner")
partner_site.register(Order, PartnerOrderAdmin)

Обратите внимание на name — он попадает в неймспейс URL, и reverse("partner:app_order_changelist") будет строить ссылки именно этой админки. У Unfold для второго сценария есть свой UnfoldAdminSite и настройка SITES с переключателем между админками в шапке.

Инлайны: редактирование связанных объектов в одной форме

Инлайн — это форма связанной модели, встроенная в форму родителя. Django даёт два класса, которые отличаются только шаблоном:

  • TabularInline рисует записи строками таблицы. Подходит для коротких форм: позиции заказа, размеры товара, телефоны контакта.
  • StackedInline рисует каждую запись как отдельную форму в столбик. Подходит, когда у связанной модели десяток полей, тексты и картинки.

Переключаться между ними можно в любой момент — API идентичен. Базовый набор атрибутов:

Атрибут Смысл
model Связанная модель (обязательно)
fk_name Какой именно внешний ключ использовать, если их несколько
extra Сколько пустых форм показать (по умолчанию 3)
min_num / max_num Минимум и максимум дочерних записей
can_delete Показывать ли чекбокс удаления
show_change_link Ссылка на полную форму редактирования дочернего объекта
verbose_name / verbose_name_plural Подписи блока
classes = ["collapse"] Свернуть блок по умолчанию
formset Свой BaseInlineFormSet для валидации набора целиком
template Полностью свой шаблон инлайна

Типичный инлайн позиций заказа, где ничего лишнего не создаётся и в одном заказе не больше 50 позиций:

class OrderItemInline(admin.TabularInline):
    model = OrderItem
    fields = ["product", "quantity", "price", "sum_display"]
    readonly_fields = ["sum_display"]
    autocomplete_fields = ["product"]
    extra = 0
    min_num = 1
    max_num = 50
    show_change_link = True

extra = 0 — почти всегда правильный выбор для существующих объектов: три пустые формы по умолчанию мешают и провоцируют случайные пустые записи. Если для новой записи нужна одна форма, а для существующей ноль — переопределяется get_extra(request, obj=None); аналогично работают get_min_num и get_max_num.

Обратите внимание: каждое поле-связь в каждой строке инлайна по умолчанию рендерит select со всеми объектами модели. Двадцать позиций заказа с выбором товара из каталога на 50 тысяч SKU — это двадцать выпадающих списков по 50 тысяч элементов в HTML. Лечится двумя способами:

class OrderItemInline(admin.TabularInline):
    model = OrderItem
    autocomplete_fields = ["product"]          # поиск через AJAX вместо полного списка

    def get_queryset(self, request):
        return super().get_queryset(request).select_related("product")

autocomplete_fields требует, чтобы в ProductAdmin были заданы search_fields — иначе Django выдаст ошибку системной проверки. Альтернатива для совсем больших справочников — raw_id_fields, там вместо списка выводится поле с ID и кнопка выбора во всплывающем окне.

Группировка полей внутри инлайна делается теми же fieldsets, что и в основной форме, а группировка самих инлайнов — только порядком в списке inlines и заголовками. Если нужны настоящие вкладки — это уже Unfold с атрибутом tab = True.

Двойной (вложенный) инлайн — инлайн внутри инлайна, например заказ → позиция → комплектующие — в стандартном Django не поддерживается вообще. Варианты: пакет django-nested-admin (4.1.6, поддерживает Django 3.2+), пакет django-admin-sortable2 для перетаскивания (2.3.1, официально до Django 5.2) или встроенный механизм Unfold. Важная оговорка: ни django-nested-admin, ни django-admin-sortable2 с Unfold не совместимы — Unfold заменяет шаблоны инлайнов своими, и чужие JS-виджеты в них не встраиваются. У Unfold есть собственные inlines у инлайна (один уровень вложенности, без many-to-many) и ordering_field для сортировки перетаскиванием, но при миграции существующий код на эти пакеты придётся переписывать.

Ещё три отличия Unfold: инлайны обязательно наследуются от unfold.admin.TabularInline/StackedInline, иначе рендерятся без стилей; у инлайнов появляется per_page для пагинации и show_count для счётчика записей; а collapsible = True разворачивает блок автоматически, если внутри есть ошибка валидации.

Экшены: массовые операции над выбранными объектами

Экшен — функция, которая получает modeladmin, request и queryset из отмеченных галочками строк. Одно действие есть всегда — «Удалить выбранные»; свои добавляются декоратором @admin.action.

@admin.action(description="Пометить как отправленные", permissions=["change"])
def mark_shipped(modeladmin, request, queryset):
    updated = queryset.filter(status="paid").update(status="shipped", shipped_at=timezone.now())
    modeladmin.message_user(request, f"Отправлено заказов: {updated}", messages.SUCCESS)

@admin.register(Order)
class OrderAdmin(admin.ModelAdmin):
    actions = [mark_shipped]

Два момента из этого примера стоит разобрать. permissions=["change"] заставляет админку проверить право app.change_order и не показывать действие тем, у кого его нет; можно указать своё право, реализовав метод has_<имя>_permission. А .update() работает одним SQL-запросом, но не вызывает save() и сигналы — если на модели висит логика в save(), нужен цикл по объектам, и тогда стоит ограничить размер выборки.

В Django 6.0 message_user по умолчанию использует уровень INFO, у которого теперь своя иконка и цвет, отличные от SUCCESS. Если хочется прежний зелёный вид — передавайте messages.SUCCESS явно, как в примере выше.

Экшены в форме объекта — новинка Django 6.1. Раньше действие можно было запустить только из списка, отметив галочку; теперь у декоратора есть аргумент location:

from django.contrib.admin import ActionLocation

@admin.action(
    location=[ActionLocation.CHANGE_FORM, ActionLocation.CHANGE_LIST],
    description="Отправить письмо клиенту",
    description_plural="Отправить письмо выбранным клиентам",
)
def send_email(modeladmin, request, queryset): ...

description показывается в форме объекта, description_plural — в выпадающем списке над таблицей. Важный нюанс: при запуске экшена из формы несохранённые изменения теряются, поэтому для действий, которые логично делать после правки, это не лучший UX. Переопределяющим get_actions и get_action_choices в 6.1 надо добавить параметр action_location — старая сигнатура объявлена устаревшей, как и распаковка словаря из get_actions (теперь там объекты Action с атрибутами func, name, description, plural_description, locations).

Промежуточная страница нужна, когда действию требуются дополнительные данные: причина отмены, новая дата доставки, размер скидки. Схема одна и та же — экшен возвращает HttpResponse вместо None:

@admin.action(description="Отменить с указанием причины")
def cancel_orders(modeladmin, request, queryset):
    form = CancelForm(request.POST if "apply" in request.POST else None)
    if "apply" in request.POST and form.is_valid():
        queryset.update(status="cancelled", cancel_reason=form.cleaned_data["reason"])
        modeladmin.message_user(request, f"Отменено: {queryset.count()}")
        return None                      # None → возврат к списку
    return render(request, "admin/cancel_orders.html", {
        "orders": queryset, "form": form,
        "selected": request.POST.getlist(admin.helpers.ACTION_CHECKBOX_NAME),
    })

В шаблоне промежуточной страницы обязательно отрисуйте скрытые поля с выбранными ID (ACTION_CHECKBOX_NAME), имя действия (action) и csrf_token — иначе после отправки формы выбор потеряется. Ту же схему используют экспорт в CSV и Excel: экшен собирает файл и возвращает HttpResponse с Content-Disposition, и никакой промежуточной страницы не требуется.

Расположение панели экшенов настраивается флагами actions_on_top (по умолчанию True), actions_on_bottom и actions_selection_counter. Убрать «Удалить выбранные» можно так: admin.site.disable_action("delete_selected") глобально или actions = [...] без него плюс get_actions, вычищающий ключ, — точечно.

Как разграничить права доступа в админке Django

Доступ в админку определяется двумя флагами пользователя и четырьмя правами на каждую модель. is_staff пускает в интерфейс, is_superuser даёт всё без проверок. Для каждой модели Django автоматически создаёт права add, change, delete и view — последнее позволяет открыть список и карточку в режиме только для чтения, без кнопок сохранения.

Права выдают не пользователям поштучно, а группам: «Контент», «Поддержка», «Бухгалтерия». Это единственный способ не сойти с ума на проекте с двадцатью сотрудниками.

Точечные проверки живут в пяти методах ModelAdmin, и все они получают request, а три из них ещё и объект:

def has_view_permission(self, request, obj=None): ...
def has_add_permission(self, request): ...
def has_change_permission(self, request, obj=None): ...
def has_delete_permission(self, request, obj=None): ...
def has_module_permission(self, request): ...      # показывать ли приложение в меню

Самая частая реальная задача — менеджер видит и правит только свои заказы. Решается в два действия: фильтрация выборки плюс проверка объекта.

def get_queryset(self, request):
    qs = super().get_queryset(request)
    return qs if request.user.is_superuser else qs.filter(manager=request.user)

def has_change_permission(self, request, obj=None):
    if obj is None or request.user.is_superuser:
        return super().has_change_permission(request, obj)
    return obj.manager_id == request.user.id

Одного get_queryset мало: без проверки в has_change_permission пользователь по-прежнему получит объект по прямому URL. Вторая частая задача — «после оплаты заказ редактировать нельзя»; она решается через get_readonly_fields, который возвращает разный набор полей в зависимости от состояния объекта.

Собственные права объявляются в модели и дальше работают как обычные:

class Order(models.Model):
    class Meta:
        permissions = [("can_refund", "Может делать возврат")]

Проверяется как request.user.has_perm("shop.can_refund"), в экшенах — через permissions=["can_refund"] у декоратора.

Гранулярные права на отдельные записи стандартный Django не умеет — его права действуют на модель целиком. Актуальные пакеты, которые это закрывают:

Пакет Версия и поддержка Что даёт
django-guardian 3.5.0 (12 сентября 2026), Django до 6.0, Python 3.10–3.14 Права на конкретный объект в БД, GuardedModelAdmin с вкладкой управления доступом на странице записи
rules 3.5, Django 3.2+ Права как функции-предикаты без записи в БД, ObjectPermissionsModelAdmin для админки
django-otp 1.7.0 (январь 2026) OTPAdminSite — вход в админку только со вторым фактором

Выбор между первыми двумя простой: если права — это данные, которые назначает администратор через интерфейс, нужен guardian; если права — это бизнес-логика («редактировать может автор или его руководитель»), нужен rules, и он не создаёт таблиц. Для Unfold у guardian есть готовая интеграция unfold.contrib.guardian.

Про двухфакторную аутентификацию стоит сказать отдельно: популярный django-two-factor-auth в версии 1.18.1 заявляет поддержку только до Django 5.2, поэтому на 6.x проще подключить django-otp напрямую и заменить admin.site на OTPAdminSite. И не забывайте банальное: админка на публичном домене по адресу /admin/ — цель для перебора паролей, поэтому её обычно переносят на нестандартный путь и закрывают по IP или VPN.

Форма редактирования: fields, fieldsets, readonly и группировка

Состав формы задают четыре взаимоисключающие по смыслу настройки. fields перечисляет поля явно, exclude наоборот убирает лишние, fieldsets группирует поля в секции, readonly_fields делает поля нередактируемыми. Одновременно fields и fieldsets использовать нельзя — Django выдаст ошибку проверки.

fieldsets = [
    (None, {"fields": ["number", "status", ("customer", "manager")]}),
    ("Доставка", {
        "fields": ["address", "delivery_date", "tracking_number"],
        "description": "Заполняется после оплаты",
    }),
    ("Служебное", {"fields": ["created_at", "source"], "classes": ["collapse"]}),
]
readonly_fields = ["number", "created_at", "total_display"]

Кортеж внутри fields (("customer", "manager")) ставит поля в одну строку — простой способ ужать длинную форму. Класс collapse сворачивает секцию; с Django 5.1 она рендерится через details/summary, то есть работает без JavaScript. Класс wide в Django 6.1 удалён.

readonly_fields принимает не только поля модели, но и методы — это стандартный приём, чтобы показать в форме вычисленное значение или HTML:

@admin.display(description="Сумма с доставкой")
def total_display(self, obj):
    return f"{obj.total + obj.delivery_price} ₽"

Динамика делается через get_fieldsets, get_fields, get_readonly_fields и get_exclude — все они получают request и obj, и все возвращают разный результат для добавления (obj is None) и редактирования. Классический пример — при создании заказа менеджер выбирает клиента, при редактировании поле становится readonly.

Остальные настройки формы, которые часто используют:

Настройка Что делает
form Своя ModelForm — валидация, кастомные виджеты, дополнительные поля
formfield_overrides Замена виджета для всех полей одного типа: {models.TextField: {"widget": CKEditorWidget}}
radio_fields Радиокнопки вместо выпадающего списка: {"status": admin.VERTICAL}
prepopulated_fields Автозаполнение слага из заголовка: {"slug": ("title",)}
save_as Кнопка «Сохранить как новый» — удобно для клонирования
save_on_top Дублировать кнопки сохранения сверху формы
view_on_site Ссылка «Смотреть на сайте», по умолчанию берётся из get_absolute_url
delete_confirmation_max_display Сколько объектов показать на странице подтверждения удаления (Django 6.1)

Про prepopulated_fields есть неочевидное ограничение: оно не работает с полями из readonly_fields и с ForeignKey. А delete_confirmation_max_display в 6.1 решает старую боль — при удалении категории с десятью тысячами товаров страница подтверждения раньше пыталась вывести их все и падала по таймауту.

Что меняется в Unfold. Стандартные fields, fieldsets, exclude, readonly_fields, inlines, radio_fields, prepopulated_fields продолжают работать. Дополнительно появляются вкладки для fieldsets ("classes": ["tab"]), условное отображение полей, WYSIWYG-редактор и виджеты для массивов и JSON, add_fieldsets для отдельного набора полей при создании. Не работают: старые сторонние виджеты со своими шаблонами (django-ckeditor и подобные) — они отрисуются без стилей, и самописные виджеты, унаследованные от классов Django.

Работа со связями в форме: автокомплит, raw ID и двойной селектор

Для полей ForeignKey и ManyToManyField админка предлагает четыре способа выбора, и разница между ними — это в первую очередь разница в количестве данных.

Способ Настройка Когда применять
Обычный выпадающий список по умолчанию До ~200 связанных объектов
Умный поиск (автокомплит) autocomplete_fields Сотни и тысячи объектов, нужен поиск по названию
Прямой ввод ID raw_id_fields Десятки тысяч объектов, пользователь знает артикул
Двойной селектор filter_horizontal / filter_vertical Множественный выбор из десятков значений: теги, категории, права
autocomplete_fields = ["customer", "manager"]
raw_id_fields = ["product"]
filter_horizontal = ["tags", "promo_campaigns"]

Для autocomplete_fields обязательное условие — в админке связанной модели должны быть заданы search_fields, иначе Django не сможет построить AJAX-эндпоинт и выдаст ошибку admin.E040. Поиск идёт по тем же правилам, что и в списке, включая префиксы.

raw_id_fields показывает текстовое поле с ID, лупу для выбора во всплывающем окне и подпись выбранного объекта рядом. Выглядит топорно, зато не тянет вообще ничего лишнего — это единственный вариант, который работает на справочнике в миллион записей.

Рядом с любым таким полем админка рисует три иконки — «плюс» для создания нового объекта, «карандаш» для редактирования выбранного и «глаз» для просмотра. Это RelatedFieldWidgetWrapper, и он открывает обычную админскую форму во всплывающем окне, а после сохранения подставляет объект в родительскую форму. Иконки появляются только если у пользователя есть соответствующие права на связанную модель.

Ограничить список доступных значений можно в formfield_for_foreignkey — например, менеджер должен выбирать только активные товары своего склада:

def formfield_for_foreignkey(self, db_field, request, **kwargs):
    if db_field.name == "product":
        kwargs["queryset"] = Product.objects.filter(is_active=True, warehouse=request.user.warehouse)
    return super().formfield_for_foreignkey(db_field, request, **kwargs)

Для ManyToManyField то же самое делает formfield_for_manytomany, для полей с choicesformfield_for_choice_field.

Что из этого не работает в Unfold. По состоянию на версию 0.106 (Django 5.2–6.1, Python 3.12+):

Механизм Состояние в Unfold
autocomplete_fields Полностью поддержан, свои виджеты, выглядит нативно
raw_id_fields для ForeignKey Поддержан, есть UnfoldForeignKeyRawIdWidget и свой шаблон
raw_id_fields для ManyToManyField Своего виджета нет — рендерится стандартным шаблоном Django и выпадает из оформления
filter_horizontal / filter_vertical Работают, но виджет FilteredSelectMultiple не стилизован: вы получите два серых окна из старой админки посреди новой формы
Иконки «плюс/карандаш/глаз» Поддержаны, у Unfold свой related_widget_wrapper.html
radio_fields Поддержаны, свой виджет
Экшены в форме объекта из Django 6.1 Шаблона change_form_actions.html в Unfold пока нет — панель отрисуется без стилей

Причём Unfold в режиме DEBUG сам подсказывает, что делать: если поле-связь не попало ни в autocomplete_fields, ни в raw_id_fields, он выводит предупреждение «Field X is not an autocomplete field» прямо в интерфейсе. Отключается это настройкой SHOW_UI_WARNINGS, а отдельные поля выводятся из-под проверки списком autocomplete_fields_excluded_from_warnings.

Как переопределить шаблоны админки Django: полный список

Любой шаблон админки можно заменить своим — достаточно положить файл с тем же путём в каталог, который стоит в DIRS раньше приложений:

TEMPLATES = [{
    "BACKEND": "django.template.backends.django.DjangoTemplates",
    "DIRS": [BASE_DIR / "templates"],
    "APP_DIRS": True,
}]

После этого templates/admin/base_site.html перекроет стандартный. Полный список шаблонов Django 6.1 и назначение каждого:

Каркас и навигация

Шаблон Назначение
admin/base.html Базовый каркас всех страниц: head, шапка, хлебные крошки, блоки для стилей и скриптов
admin/base_site.html Брендирование — заголовок и логотип. Самый частый кандидат на переопределение
admin/index.html Главная страница: список приложений и блок последних действий
admin/app_index.html Страница одного приложения
admin/app_list.html Сам список приложений и моделей, используется и на главной, и в сайдбаре
admin/nav_sidebar.html Боковое меню навигации
admin/color_theme_toggle.html Переключатель светлой и тёмной темы
admin/login.html Страница входа
admin/404.html, admin/500.html Страницы ошибок внутри админки
admin/invalid_setup.html Сообщение о некорректно настроенном фильтре

Список объектов

Шаблон Назначение
admin/change_list.html Страница списка целиком
admin/change_list_results.html Только таблица результатов
admin/change_list_object_tools.html Кнопки над списком, в первую очередь «Добавить»
admin/search_form.html Форма поиска
admin/filter.html Один блок фильтра в боковой панели
admin/date_hierarchy.html Навигация по датам
admin/pagination.html Постраничная навигация
admin/actions.html Панель выбора экшена над таблицей

Форма объекта

Шаблон Назначение
admin/change_form.html Форма добавления и редактирования
admin/change_form_object_tools.html Кнопки «История» и «Смотреть на сайте»
admin/change_form_actions.html Панель экшенов в форме объекта (Django 6.1)
admin/includes/fieldset.html Рендер одной секции полей
admin/submit_line.html Строка кнопок «Сохранить», «Сохранить и продолжить», «Удалить»
admin/edit_inline/tabular.html Инлайн в виде таблицы
admin/edit_inline/stacked.html Инлайн в виде стопки форм
admin/prepopulated_fields_js.html JS для автозаполнения слагов
admin/popup_response.html Ответ всплывающего окна «добавить связанный объект»

Удаление, история, служебное

Шаблон Назначение
admin/delete_confirmation.html Подтверждение удаления одного объекта
admin/delete_selected_confirmation.html Подтверждение массового удаления через экшен
admin/includes/object_delete_summary.html Сводка каскадно удаляемых объектов
admin/object_history.html История изменений объекта
admin/auth/user/add_form.html Форма создания пользователя
admin/auth/user/change_password.html Форма смены пароля
admin/widgets/*.html Виджеты: date, time, split_datetime, radio, url, clearable_file_input, foreign_key_raw_id, many_to_many_raw_id, related_widget_wrapper
registration/*.html Вход, выход, смена и восстановление пароля

Заменять шаблон целиком почти никогда не нужно — правильнее унаследоваться и переопределить один блок:

{% extends "admin/change_list.html" %}

{% block object-tools-items %}
  <li><a href="{% url 'admin:shop_order_export' %}" class="addlink">Выгрузить в CSV</a></li>
  {{ block.super }}
{% endblock %}

Точечно, для конкретного приложения или модели, шаблон переопределяется по одному из двух путей:

templates/admin/<приложение>/<модель>/change_list.html   # только для этой модели
templates/admin/<приложение>/change_list.html            # для всех моделей приложения

Так переопределяются не все шаблоны, а только шестнадцать:

actions.html                    date_hierarchy.html
app_index.html                  delete_confirmation.html
change_form.html                object_history.html
change_form_actions.html        pagination.html
change_form_object_tools.html   popup_response.html
change_list.html                prepopulated_fields_js.html
change_list_object_tools.html   search_form.html
change_list_results.html        submit_line.html

Остальные — в том числе base_site.html, index.html и delete_selected_confirmation.html — переопределяются только глобально, через папку templates проекта.

Третий способ — атрибуты ModelAdmin. Они указывают на произвольный путь, и структуру каталогов соблюдать не нужно:

class OrderAdmin(admin.ModelAdmin):
    change_list_template = "shop/order/list.html"
    change_form_template = "shop/order/form.html"
    add_form_template = "shop/order/add.html"
    object_history_template = "shop/order/history.html"
    delete_confirmation_template = "shop/order/delete.html"
    delete_selected_confirmation_template = "shop/order/delete_selected.html"
    popup_response_template = "shop/order/popup.html"

Как подключить свои JS и CSS и не потерять состояние списка

Свои файлы подключаются через вложенный класс Media — он работает и в ModelAdmin, и в формах, и в виджетах:

class OrderAdmin(admin.ModelAdmin):
    class Media:
        css = {"all": ["shop/admin/order.css"]}
        js = ["shop/admin/order_status.js"]

Пути отсчитываются от STATIC_URL, порядок в списке сохраняется, дубликаты Django убирает сам. jQuery в админке уже есть, но в собственном неймспейсе — обращаться к нему надо как django.jQuery, иначе скрипт сломается на проектах, где глобального $ нет.

Скрипт на всю админку сразу подключают через переопределение admin/base.html — с Django 5.2 для этого появился отдельный блок extrabody, который рендерится перед закрывающим тегом body, то есть скрипт получает уже готовый DOM.

Сохранение состояния списка — это preserve_filters, включён по умолчанию. Благодаря ему после сохранения объекта админка возвращает вас в тот же отфильтрованный и отсортированный список, а не в начало: набор фильтров едет в GET-параметре _changelist_filters. Выключать его стоит только осознанно. Своё состояние (свёрнутые секции, ширина колонок) сохраняют в localStorage из скрипта, подключённого через Media, — в самом Django такого механизма нет, а вот Unfold, например, запоминает состояние сайдбара и тему.

Как добавить свою страницу в админку Django

Свои страницы добавляются через get_urls — метод возвращает список URL-паттернов, к которым Django добавит стандартные. Обёртка self.admin_site.admin_view обязательна: она проверяет, что пользователь залогинен и является сотрудником, и отключает кеширование.

class OrderAdmin(admin.ModelAdmin):
    def get_urls(self):
        return [
            path("report/", self.admin_site.admin_view(self.report_view), name="shop_order_report"),
        ] + super().get_urls()

    def report_view(self, request):
        context = {
            **self.admin_site.each_context(request),
            "title": "Отчёт по продажам",
            "rows": Order.objects.values("status").annotate(total=Sum("total")),
        }
        return TemplateResponse(request, "admin/shop/report.html", context)

Ключевая деталь — each_context(request): он кладёт в контекст заголовок сайта, меню приложений, права текущего пользователя и всё остальное, без чего шаблон, унаследованный от admin/base_site.html, отрисуется сломанным. Свои URL должны идти до super().get_urls(), иначе паттерн <path:object_id>/ перехватит report/ и попытается найти заказ с таким идентификатором.

Страницы, не привязанные к модели, добавляют в подклассе AdminSite — там тот же get_urls, плюс можно переопределить index_template и each_context, чтобы главная показывала дашборд. Оформление шапки настраивается тремя строками:

admin.site.site_header = "Управление магазином"
admin.site.site_title = "Магазин"
admin.site.index_title = "Разделы"

Когда стандартных возможностей не хватает — нужны графики, KPI-карточки, отчёты с фильтрами — разумнее не писать вёрстку с нуля, а взять тему с готовыми компонентами. В обзоре django-unfold разобраны DASHBOARD_CALLBACK, библиотека компонентов для дашбордов и UnfoldModelAdminViewMixin, который превращает описанный выше get_urls в обычный class-based view со стилями. Если у вас нет возможности сделать из вашей админки полноценный внутренний инструмент, мы делаем это под ключ — от аудита существующих ModelAdmin до дашбордов и разграничения прав, пишите нам через форму на сайте.

Кастомные кверисеты и обработчики событий в админке

Все страницы админки строятся поверх одного метода — get_queryset(request). Это место для оптимизации, фильтрации по правам и для вычисляемых колонок.

def get_queryset(self, request):
    return (
        super()
        .get_queryset(request)
        .select_related("customer", "manager")          # FK — один JOIN
        .prefetch_related("items__product")             # обратные связи и M2M
        .annotate(items_count=Count("items"))
    )

@admin.display(description="Позиций", ordering="items_count")
def items_count(self, obj):
    return obj.items_count

Связка annotate + ordering в @admin.display применяется, когда необходимо получить сортируемую вычисляемую колонку. Без select_related список из 100 заказов с колонкой customer__email даст 101 запрос; с ним — один. Проверять это удобно через django-debug-toolbar, он работает и на страницах админки.

Начиная с Django 6.1 list_select_related = False (значение по умолчанию) уже подтягивает внешние ключи, которые встречаются в list_display, так что часть ручной оптимизации стала не нужна. А вот list_select_related = True объявлено устаревшим — вместо него перечисляйте поля списком.

Обработчики событий в админке — это не сигналы, а методы ModelAdmin, которые вызываются в конкретных точках сохранения и удаления:

Метод Когда вызывается
save_model(request, obj, form, change) Перед сохранением объекта; changeFalse при создании
save_formset(request, form, formset, change) Сохранение одного набора инлайнов
save_related(request, form, formsets, change) После объекта, перед и вокруг сохранения всех связей
delete_model(request, obj) Удаление одного объекта
delete_queryset(request, queryset) Массовое удаление через экшен
response_add / response_change / response_delete Куда перенаправить после действия
get_changeform_initial_data(request) Начальные значения полей новой записи
log_addition / log_change / log_deletions Запись в журнал LogEntry

Типичный случай — проставить автора и отправить уведомление только при реальной смене статуса:

def save_model(self, request, obj, form, change):
    if not change:
        obj.manager = request.user
    if change and "status" in form.changed_data:
        notify_customer.delay(obj.pk, obj.status)
    super().save_model(request, obj, form, change)

form.changed_data содержит список полей, которые действительно изменились. Без этой проверки уведомление уйдёт на каждое нажатие «Сохранить», даже если менеджер просто поправил опечатку в комментарии. Если логика зависит от инлайнов (например, пересчёт суммы заказа по позициям), её место — в save_related, потому что на момент save_model позиции ещё не сохранены.

Своя кнопка в форме делается связкой шаблона и response_change: в переопределённом submit_line.html добавляется кнопка с атрибутом name, а метод ловит её в request.POST:

def response_change(self, request, obj):
    if "_recalculate" in request.POST:
        obj.recalculate_total()
        self.message_user(request, "Сумма пересчитана")
        return HttpResponseRedirect(request.path)
    return super().response_change(request, obj)

И отдельно про сигналы: pre_save/post_save сработают при любом сохранении — из админки, из команды, из API. Методы ModelAdmin срабатывают только в админке. Логику, которая должна работать всегда, кладите в сигналы или в Model.save(), логику «именно так делает оператор в интерфейсе» — в save_model. Смешивать эти два уровня в одном проекте — самый надёжный способ потом полдня искать, почему статус меняется дважды.

Что дальше

  • Если стандартный интерфейс устраивает функционально, но не устраивает визуально, — django-unfold: современная админка Django: установка, сайдбар, табы, дашборды и список того, что ломается при миграции.
  • Если админка превратилась в инструмент аналитики и annotate в get_queryset уже не хватает, посмотрите обзор Apache Superset — интерактивные дашборды с drill-down поверх той же базы.

Коротко

Как вывести в списке админки Django поле связанной модели или вычисляемое значение?

С Django 5.1 в list_display работает двойное подчёркивание: list_display = ["number", "customer__email"]. Для вычисляемых значений добавьте метод в ModelAdmin и повесьте @admin.display с параметрами description, ordering и boolean; свойства с @property тоже выводятся, но сортировать по ним нельзя.

Как настроить поиск по связанной модели в админке Django?

В search_fields используется «follow»-нотация ORM: search_fields = ["number", "customer__email", "items__sku"]. Префикс ^ даёт istartswith, =iexact, @ — полнотекстовый поиск; фраза в кавычках ищется целиком. Для сложной логики переопределяется get_search_results.

Чем TabularInline отличается от StackedInline и когда что использовать?

TabularInline рисует связанные объекты строками таблицы — это компактно и подходит для позиций заказа и записей на 3–5 полей. StackedInline рисует каждый объект отдельной формой в столбик и удобен для длинных форм с текстами и картинками. Функционально классы одинаковы, отличается только шаблон.

Как сделать так, чтобы пользователь видел в админке только свои объекты?

Отфильтруйте выборку в ModelAdmin.get_queryset по request.user и продублируйте проверку в has_change_permission и has_delete_permission — иначе объект останется доступен по прямому URL. Права на отдельные записи, а не на модель целиком, дают пакеты django-guardian и rules.

Какие шаблоны админки Django можно переопределить?

Глобально — любой шаблон из django/contrib/admin/templates, если положить файл с тем же путём в свою папку templates. Точечно, для приложения или модели, — только change_form.html, change_list.html, change_list_results.html, change_form_actions.html, actions.html, app_index.html, object_history.html, delete_confirmation.html, pagination.html, popup_response.html, search_form.html, submit_line.html, date_hierarchy.html, prepopulated_fields_js.html и шаблоны object tools.

Что нового в админке Django 6.0 и 6.1?

В Django 6.0 обновлены иконки до Font Awesome 6.7.2, добавлен AdminSite.password_change_form, разведено оформление сообщений уровней DEBUG и INFO. В Django 6.1 экшены научились появляться в форме объекта через location у @admin.action, изменён порядок лейбла, подсказки и поля в формах, list_select_related по умолчанию выбирает только нужные внешние ключи, а delete_confirmation_max_display ограничивает список на странице подтверждения удаления.