Встроенная админка 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= — это нужно, когда в проекте несколько админ-сайтов.
Валидация в админке многослойная, и знать порядок полезно, потому что ошибки в разных слоях выглядят по-разному:
- Валидаторы поля модели —
validators=[...],max_length,choices,null/blank. Model.clean_fields()— приведение типов и проверка каждого поля по отдельности.ModelForm.clean_<имя_поля>()— ваша проверка одного поля, результат надо вернуть.Model.clean()иModelForm.clean()— проверки, которым нужны несколько полей сразу.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, для полей с choices — formfield_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) |
Перед сохранением объекта; change — False при создании |
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 ограничивает список на странице подтверждения удаления.