Admin Customization

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Django 6.1 (6.1.1, released 2026-09-02; 6.1.2 is scheduled for 2026-10-06), Python 3.12-3.14 and PostgreSQL 15 or newer, all checked on 2026-10-03; where the 5.2 LTS (5.2.17) behaves differently the text says so. Every example was run on that stack in a scratch project on SQLite with the Django test client, the runserver development server and, for the JavaScript, Node.js 24; none of them needs PostgreSQL or Redis. The third-party packages used on this page are django-otp 1.7.3 (released 2026-09-06, with no Django classifier on PyPI, but verified here on Django 6.1.1) and docutils 0.23 (2026-05-27, which satisfies the 0.22+ requirement of 6.1). The page starts where Admin Site stops: that page configures what the admin shows for each model with ModelAdmin; this one changes the admin itself, that is, the site object, its URLs, its templates, its scripts and its exposure to the internet. The running example is the Bookshelf project with its catalog app (Author, Book and Review models, see Models & Fields) and a project package called config.

In a hurry: what to use for what

Need Rule of thumb Section

Change the title, the index page or the order of apps

Subclass AdminSite, set site_header, site_title, index_title, override get_app_list(), and make it the default site with an AdminConfig.default_site.

A custom AdminSite

Give two audiences two admins

Instantiate several AdminSite objects with different names and mount each at its own URL.

Several admin sites in one project

Add a report or tool page inside the admin

get_urls() on the site or on a ModelAdmin, every view wrapped in admin_site.admin_view().

Custom views: get_urls() and admin_view

Change markup or colours

Override templates under templates/admin/, override one block rather than a whole template, and use the CSS variables for colours.

Overriding templates

Run JavaScript in the admin

Static files loaded from a change_form.html block, the Media class, and the formset:added and formset:removed events.

JavaScript customizations

Browse model, view and template docstrings

django.contrib.admindocs plus docutils, mounted before the admin URL.

The admindocs app

Keep attackers out

HTTPS everywhere, a second factor, an allowlist, least-privilege staff accounts, the catch-all view left on, and not exposing the admin at all when it is not needed.

Securing the admin

Offer something to customers or readers

Do not use the admin; write views, templates and forms.

The admin is not the end-user UI

The admin is not the end-user UI

The admin is an internal back office for trusted staff. The Django documentation says so in its introduction to the app: its recommended use is limited to an organization’s internal management tool, and it is not intended for building an entire front end around. The reasons are structural:

  • Every screen is generated from the model. Workflows that span models, wizards, carts, public search or branded layouts fight the framework instead of using it.

  • The permission model is per model (add, change, delete and view), not per business rule, so every row a staff user can open is a row they can edit in the same form.

  • All staff share one surface. A bug in a customization is a bug on the page where the most privileged accounts live.

The pragmatic division of labour is: the admin for staff curation and data fixes, ordinary views for everything else. A customization is justified when it makes staff faster at curation (an extra report, a tidier index page, a second admin for the support desk). When the temptation is to bolt a customer-facing feature onto the admin, build a view with class-based views or function-based views instead.

Source: The Django admin site (introduction)

There is a middle ground: an ordinary view for staff only that does not need the admin layout. The decorator staff_member_required from django.contrib.admin.views.decorators applies the same "active staff" check that the admin uses and redirects to the admin login view (admin:login) otherwise.

A plain staff-only view outside the admin (catalog/views.py)
from django.contrib.admin.views.decorators import staff_member_required
from django.http import HttpResponse

from .models import Book


@staff_member_required
def staff_overview(request):
    # Plain view, no admin layout: only the "active staff" check is applied.
    return HttpResponse(f"{Book.objects.count()} books")

Source: Using the Django authentication system (the note on staff_member_required)

The view is tested below in Testing the customizations: an anonymous request is redirected to /admin/login/?next=/catalog/staff/, a staff user gets the page. The difference from an admin_view (Custom views: get_urls() and admin_view) is the context: this view has no admin navigation, breadcrumbs or each_context() variables, and it is not sent a Cache-Control: no-cache header.

A custom AdminSite

django.contrib.admin.site is an instance of django.contrib.admin.sites.AdminSite, created when the admin app is loaded. Everything the admin does for a site (the index page, login, logout, password change, the per-app pages, the jsi18n view, the URLs of every registered ModelAdmin) lives on that class, so customizing the admin as a whole means subclassing it. An AdminSite takes one argument, name, which becomes the instance namespace of its URLs ("admin" by default).

Attributes and methods worth knowing

Member What it does

site_header, site_title, index_title

The text at the top of each page, at the end of each <title>, and at the top of the index page ("Django administration", "Django site admin" and "Site administration" by default).

site_url

Target of the "View site" link; / by default, None removes the link. Behind a sub-path each_context() falls back to SCRIPT_NAME.

index_template, app_index_template

Templates for the index and the per-app index pages.

login_template, login_form, logout_template

The login page, the form (a subclass of AuthenticationForm) and the logout page.

password_change_template, password_change_done_template, password_change_form

Password change pages; password_change_form (a PasswordChangeForm subclass) is new in Django 6.0.

enable_nav_sidebar

Whether the navigation sidebar appears on large screens (True; the sidebar arrived in 3.1).

empty_value_display

The placeholder for empty values in change lists (a dash).

final_catch_all_view

True by default: a last URL pattern that sends unauthenticated visitors to the login page for any unmatched URL, so that the admin does not reveal which model URLs exist (3.2). Setting it to False is not recommended.

each_context(request)

The dictionary of variables every admin page receives (site_header, site_title, site_url, has_permission, available_apps, is_popup, is_nav_sidebar_enabled, log_entries).

get_app_list(request, app_label=None)

The apps and models for the current user, sorted alphabetically; override it to change the order on the index page (4.1).

has_permission(request)

True for active staff users by default; the single place that decides who may enter the site.

get_log_entries(request)

The LogEntry queryset shown in the "Recent actions" panel (5.0).

get_model_admin(model)

The ModelAdmin registered for a model class, or NotRegistered (5.0).

get_urls(), admin_view(view, cacheable=False)

Extension points for custom pages, see Custom views: get_urls() and admin_view.

register(), unregister()

The same operations the module-level admin.site offers.

Source: AdminSite objects (attributes and methods)

Writing the site

The Bookshelf site below sets the texts, reorders the apps so that the catalog comes first, adds an environment variable to every page context, adds a dashboard view (explained in Custom views: get_urls() and admin_view) and customizes the password change form. The LongPasswordChangeForm example uses the password_change_form attribute added in 6.0; on 5.2 the attribute is ignored, so the form is simply not used there.

A custom AdminSite (config/admin.py)
import os

from django.contrib import admin
from django.contrib.auth.forms import PasswordChangeForm
from django.core.exceptions import PermissionDenied, ValidationError
from django.db.models import Avg, Count
from django.db.models.functions import TruncYear
from django.template.response import TemplateResponse
from django.urls import path

from catalog.models import Author, Book

APP_ORDER = ["catalog", "auth"]


class LongPasswordChangeForm(PasswordChangeForm):
    def clean_new_password1(self):
        password = self.cleaned_data["new_password1"]
        if len(password) < 14:
            raise ValidationError("Admin passwords need at least 14 characters.")
        return password


class BookshelfAdminSite(admin.AdminSite):
    site_header = "Bookshelf administration"
    site_title = "Bookshelf admin"
    index_title = "Back office"
    index_template = "admin/bookshelf_index.html"
    password_change_form = LongPasswordChangeForm  # Django 6.0+

    def each_context(self, request):
        context = super().each_context(request)
        context["environment"] = os.environ.get("BOOKSHELF_ENV", "development")
        return context

    def get_app_list(self, request, app_label=None):
        app_list = super().get_app_list(request, app_label)
        # Show the catalog first, then authentication, then anything else alphabetically.
        app_list.sort(
            key=lambda app: (
                APP_ORDER.index(app["app_label"])
                if app["app_label"] in APP_ORDER
                else len(APP_ORDER),
                app["name"],
            )
        )
        return app_list

    def get_urls(self):
        custom_urls = [
            path("dashboard/", self.admin_view(self.dashboard_view), name="dashboard"),
        ]
        # Custom patterns first: the built-in admin patterns match almost anything.
        return custom_urls + super().get_urls()

    def dashboard_view(self, request):
        if not request.user.has_perm("catalog.view_book"):
            raise PermissionDenied
        request.current_app = self.name  # keeps {% url 'admin:...' %} on this site
        authors = Author.objects.annotate(
            book_count=Count("books"), average_price=Avg("books__price")
        ).order_by("-book_count", "name")
        by_year = (
            Book.objects.annotate(year=TruncYear("published"))
            .values("year")
            .annotate(books=Count("pk"))
            .order_by("year")
        )
        context = {
            **self.each_context(request),
            "title": "Catalog dashboard",
            "authors": authors,
            "by_year": by_year,
        }
        return TemplateResponse(request, "admin/catalog_dashboard.html", context)

Two details deserve attention. get_app_list() returns dictionaries that carry app_label, name, app_url, has_module_perms and models, and what the index page shows is exactly what the user is allowed to see, so sorting that list never leaks anything. And each_context() is the right place for variables that every admin page needs (an environment banner, a support link): the templates in Overriding templates read environment from it.

Making it the default site

There are two ways to put a custom site into use. Which one fits depends on whether the project wants the usual @admin.register and autodiscovery to keep working with the new site.

Replace the default site. Subclass django.contrib.admin.apps.AdminConfig, set default_site to the dotted path of the site class (or of a callable that returns a site) and list that app config instead of django.contrib.admin. From then on admin.site is a BookshelfAdminSite, @admin.register(Book) registers on it, and autodiscovery still imports every app’s admin.py. This is the approach used by Bookshelf.

The app config and settings that make BookshelfAdminSite the default (config/apps.py and settings.py)
# config/apps.py
from django.contrib.admin.apps import AdminConfig


class BookshelfAdminConfig(AdminConfig):
    # Replaces "django.contrib.admin" in INSTALLED_APPS; admin.site becomes a BookshelfAdminSite.
    default_site = "config.admin.BookshelfAdminSite"


# settings.py
INSTALLED_APPS = [
    "config.apps.BookshelfAdminConfig",  # replaces "django.contrib.admin"
    "django.contrib.admindocs",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",
    "catalog",
]

Instantiate a separate site. The older way, and still the right one for a second site, is to create the instance yourself, register models on it explicitly and mount its urls. If that instance is the only site, the documentation suggests listing django.contrib.admin.apps.SimpleAdminConfig instead of django.contrib.admin, which turns autodiscovery off so that the per-app admin.py modules, which would register on the default site, are not imported implicitly.

A stand-alone site with SimpleAdminConfig (urls.py and settings.py)
# config/urls.py
from django.contrib import admin
from django.urls import path

from catalog.models import Author


class MyAdminSite(admin.AdminSite):
    site_header = "Monty Python administration"


admin_site = MyAdminSite(name="myadmin")
admin_site.register(Author)

urlpatterns = [path("myadmin/", admin_site.urls)]


# settings.py
INSTALLED_APPS = [
    "django.contrib.admin.apps.SimpleAdminConfig",  # instead of "django.contrib.admin"
    # ...
]

Both variants were run: the first one is the Bookshelf project (all tests below), and the second was checked with the test client (/myadmin/login/ answers 200 and shows "Monty Python administration", reverse("myadmin:index") is /myadmin/, and admin.site has nothing registered because nothing was autodiscovered). An AdminSite is hooked into the URLconf by pointing a path at its urls attribute; include() is not needed.

Several admin sites in one project

Because everything lives on the instance, a project can run several admins side by side. The documentation’s own example mounts a "basic" and an "advanced" admin; a more common reason is audience: one site for editors, one for support staff with read-only access and stricter login. Bookshelf gets an ops site for the support desk. It subclasses django_otp.admin.OTPAdminSite, which is an AdminSite whose has_permission() also requires a verified one-time-password device (see A second factor), and registers Author and Book with options that remove every write permission.

A second site and its read-only registrations (config/admin.py and catalog/admin.py)
# config/admin.py
from django_otp.admin import OTPAdminSite


class OpsAdminSite(OTPAdminSite):
    """A second, read-mostly site for support staff; requires a verified OTP device."""

    site_header = "Bookshelf operations"
    site_title = "Bookshelf ops"
    index_title = "Support desk"


ops_site = OpsAdminSite(name="ops")


# catalog/admin.py
from config.admin import ops_site


class ReadOnlyAdmin(admin.ModelAdmin):
    def has_add_permission(self, request):
        return False

    def has_change_permission(self, request, obj=None):
        return False

    def has_delete_permission(self, request, obj=None):
        return False


ops_site.register(Author, ReadOnlyAdmin)
ops_site.register(Book, ReadOnlyAdmin)

Each site is mounted at a different URL, next to the pieces explained in later sections:

The project URLconf (config/urls.py)
from django.conf import settings
from django.contrib import admin
from django.contrib.auth import views as auth_views
from django.urls import include, path

from .admin import ops_site

admin_prefix = settings.ADMIN_URL  # "admin/" unless BOOKSHELF_ADMIN_URL says otherwise

urlpatterns = [
    path("catalog/", include("catalog.urls")),
    # Password reset for staff: these must come before the admin patterns.
    path(
        f"{admin_prefix}password_reset/",
        auth_views.PasswordResetView.as_view(
            extra_context={"site_header": admin.site.site_header}
        ),
        name="admin_password_reset",
    ),
    path(
        f"{admin_prefix}password_reset/done/",
        auth_views.PasswordResetDoneView.as_view(
            extra_context={"site_header": admin.site.site_header}
        ),
        name="password_reset_done",
    ),
    path(
        "reset/<uidb64>/<token>/",
        auth_views.PasswordResetConfirmView.as_view(
            extra_context={"site_header": admin.site.site_header}
        ),
        name="password_reset_confirm",
    ),
    path(
        "reset/done/",
        auth_views.PasswordResetCompleteView.as_view(
            extra_context={"site_header": admin.site.site_header}
        ),
        name="password_reset_complete",
    ),
    # admindocs must come before the admin it lives under.
    path(f"{admin_prefix}doc/", include("django.contrib.admindocs.urls")),
    path(admin_prefix, admin.site.urls),
    path("ops/", ops_site.urls),
]

The four password reset patterns are the ones from the documentation. The presence of a URL named admin_password_reset is what makes the "Forgotten your password?" link appear under the admin login form; requests for /admin/password_reset/ answered 200 on the development server. The reset flow itself is described on Passwords.

Reversing URLs across sites

The URL names of an admin are registered in the application namespace admin and in an instance namespace equal to the site name. That is why reverse("admin:catalog_book_changelist") and reverse("ops:catalog_book_changelist") produce different paths, and why the name argument matters as soon as there is more than one site. A bare reverse("admin:…​") finds the first registered instance of the admin application; to target a specific one, pass the current_app hint.

Reversing across sites (checked in the test client)
from django.urls import reverse

reverse("admin:catalog_book_changelist")                     # "/admin/catalog/book/"
reverse("ops:catalog_book_changelist")                       # "/ops/catalog/book/"
reverse("admin:catalog_book_changelist", current_app="ops")  # "/ops/catalog/book/"

The names that every site provides are index, login, logout, password_change, password_change_done, jsi18n, app_list (with app_label) and view_on_site (with content_type_id and object_id); every registered model adds {app_label}_{model_name}_changelist, _add, _history, _delete and _change. Inside admin templates the admin_urlname filter (from {% load admin_urls %}) builds the model-specific ones from opts.

Custom views: get_urls() and admin_view

Both AdminSite and ModelAdmin have a get_urls() method that returns a URLconf fragment, so adding a page to the admin is a matter of extending the list. This is how an internal dashboard, an export screen or a reconciliation report lives inside the admin with the admin’s look, navigation and login.

A site-level dashboard

The BookshelfAdminSite in Writing the site already contains the pattern. Three rules make the view behave as an admin page.

  1. Wrap the view in self.admin_view(). That wrapper runs the permission check (has_permission(): active staff, plus any extra condition the site adds) and applies never_cache() so that the page is never served stale. Skipping it exposes the page to anonymous users.

  2. Put the custom patterns before super().get_urls(). The built-in patterns are permissive and, with the catch-all view, match almost every path; a custom pattern added afterwards may never be reached.

  3. Build the context from each_context(request) and set request.current_app. The first gives the template the navigation, available_apps and the site texts; the second makes {% url 'admin:…​' %} resolve inside this site when more than one exists. Use self.name on an AdminSite and self.admin_site.name on a ModelAdmin.

admin_view() only proves that the user is staff. It says nothing about model permissions, which is why the dashboard checks request.user.has_perm("catalog.view_book") and raises PermissionDenied (a 403) otherwise. Without that check, any staff account, even one with no permissions at all, could read the aggregate figures.

The dashboard template extends admin/base_site.html to inherit the layout and fills the content block:

The dashboard template (catalog/templates/admin/catalog_dashboard.html)
{% extends "admin/base_site.html" %}

{% block breadcrumbs %}
<div class="breadcrumbs">
  <a href="{% url 'admin:index' %}">Home</a> &rsaquo; {{ title }}
</div>
{% endblock %}

{% block content %}
<div id="content-main">
  <h2>Books per author</h2>
  <table>
    <thead><tr><th>Author</th><th>Books</th><th>Average price</th></tr></thead>
    <tbody>
    {% for author in authors %}
      <tr><td>{{ author.name }}</td><td>{{ author.book_count }}</td><td>{{ author.average_price|default_if_none:"-" }}</td></tr>
    {% endfor %}
    </tbody>
  </table>
  <h2>Books per publication year</h2>
  <ul>
    {% for row in by_year %}<li>{{ row.year|date:"Y" }}: {{ row.books }}</li>{% endfor %}
  </ul>
</div>
{% endblock %}

The queries are the aggregation tools of Aggregation, Expressions & Functions (annotate(Count), Avg, TruncYear). To link the dashboard from the index page, the site sets index_template to a template that extends the default one:

Linking the dashboard from the index (catalog/templates/admin/bookshelf_index.html)
{% extends "admin/index.html" %}
{% block content %}
<p><a href="{% url 'admin:dashboard' %}">Open the catalog dashboard</a></p>
{{ block.super }}
{% endblock %}

Run on the scratch project with the Django test client, an anonymous request to /admin/dashboard/ is redirected to /admin/login/?next=/admin/dashboard/, a staff user without view_book receives 403, a staff user with it receives the page with a Cache-Control header containing no-cache, and reverse("admin:dashboard") is /admin/dashboard/.

A view on one model

A ModelAdmin extends its own URLs the same way, and its URLs sit below the model’s: with the admin mounted at /admin/, a pattern price-report/ on the Book admin answers at /admin/catalog/book/price-report/. The view calls self.has_view_permission(request), the same hook the change list uses, so that the page follows the model permissions that staff were given in the admin (see Permissions & Authorization).

A model-level report (catalog/admin.py)
from django.contrib import admin
from django.core.exceptions import PermissionDenied
from django.db.models import Avg, Max, Min
from django.template.response import TemplateResponse
from django.urls import path

from .models import Author, Book, Review


class ReviewInline(admin.TabularInline):
    model = Review
    extra = 1


@admin.register(Author)
class AuthorAdmin(admin.ModelAdmin):
    list_display = ["name", "slug"]
    prepopulated_fields = {"slug": ["name"]}
    search_fields = ["name"]


@admin.register(Book)
class BookAdmin(admin.ModelAdmin):
    list_display = ["title", "author", "price", "published"]
    inlines = [ReviewInline]
    change_list_template = "admin/catalog/book/change_list.html"
    change_form_template = "admin/catalog/book/change_form.html"

    class Media:
        css = {"all": ["catalog/admin.css"]}

    def get_urls(self):
        my_urls = [
            path(
                "price-report/",
                self.admin_site.admin_view(self.price_report_view),
                name="catalog_book_price_report",
            ),
        ]
        return my_urls + super().get_urls()

    def price_report_view(self, request):
        if not self.has_view_permission(request):
            raise PermissionDenied
        request.current_app = self.admin_site.name
        stats = Book.objects.aggregate(low=Min("price"), high=Max("price"), mean=Avg("price"))
        context = {
            **self.admin_site.each_context(request),
            "opts": self.model._meta,
            "title": "Price report",
            "stats": stats,
        }
        return TemplateResponse(request, "admin/catalog/book/price_report.html", context)

Passing "opts" lets the template build the breadcrumbs and admin_urlname links of the model. If a page is expensive but safe to cache, admin_view(view, cacheable=True) keeps the permission check but drops never_cache. The change-list template override that links to this report is in Per-app and per-model templates. The report was requested in the test client by a staff user with only view_book: it answered 200 and showed Mean: 15 for two books priced 10.00 and 20.00.

Overriding templates

The admin renders everything with the ordinary Django template engine, so the standard mechanics apply: a template found earlier in the loader search path wins over django/contrib/admin/templates/admin/…​. Project-wide overrides go in a directory listed in TEMPLATES["DIRS"] (the admin app’s own templates are found through APP_DIRS, after DIRS); overrides that belong to one app go in that app’s templates/admin/ directory. If the loaders option is customised the filesystem loader must come before the app directories loader.

TEMPLATES with a project directory (settings.py)
TEMPLATES = [
    {
        "BACKEND": "django.template.backends.django.DjangoTemplates",
        "DIRS": [BASE_DIR / "templates"],
        "APP_DIRS": True,
        "OPTIONS": {
            "context_processors": [
                "django.template.context_processors.request",
                "django.contrib.auth.context_processors.auth",
                "django.contrib.messages.context_processors.messages",
            ],
        },
    },
]

The three context processors are not optional: the system checks admin.E402, admin.E404 and admin.W411 complain when the auth processor, the messages processor or the request processor (needed for the navigation sidebar) is missing.

Override a block, do not replace the file

The documentation is explicit that replacing a whole admin template is rarely advisable. Copying a 100-line template to change a link means that every future Django release that touches it is a merge you did not ask for. The practice is to {% extends %} the original and override only the block that needs to change. The admin templates have many blocks (object-tools-items, field_sets, after_field_sets, submit_buttons_bottom, result_list, pagination, and so on); reading the template in the installed django/contrib/admin/templates/admin/ directory shows which ones exist in your version. The Django base.html also defines extrastyle, extrahead, header, branding, usertools, nav-global, nav-breadcrumbs, nav-sidebar, footer, dark-mode-vars and extrabody.

The project-wide branding override (templates/admin/base_site.html)
{% extends "admin/base.html" %}
{% load static %}

{% block title %}{% if subtitle %}{{ subtitle }} | {% endif %}{{ title }} | {{ site_title|default:_('Django site admin') }}{% endblock %}

{% block branding %}
<div id="site-name">
  <a href="{% url 'admin:index' %}">
    <img src="{% static 'catalog/logo.svg' %}" alt="" height="24" width="24">
    {{ site_header|default:_('Django administration') }}
  </a>
</div>
{% if user.is_anonymous %}
  {% include "admin/color_theme_toggle.html" %}
{% endif %}
{% endblock %}

{% block nav-global %}{% endblock %}

This is the tutorial’s base_site.html exercise done on the 6.1 template: the file is the stock base_site.html with a logo added in the branding block. Note that the stock template includes the colour theme toggle for anonymous users, and a copy made from an older release lacks it; keeping that include is how a customised branding stays in step. As the tutorial itself says, for just the text the site_header attribute is the easier route; override the template when the markup has to change.

Per-app and per-model templates

Within templates/admin/ a subdirectory named after the app, and under it one named after the (lowercase) model, narrows the override: templates/admin/catalog/change_list.html applies to every model of catalog, and templates/admin/catalog/book/change_list.html only to Book. Only some templates can be overridden at app or model level: actions.html, app_index.html, change_form.html, change_form_actions.html, change_form_object_tools.html, change_list.html, change_list_object_tools.html, change_list_results.html, date_hierarchy.html, delete_confirmation.html, object_history.html, pagination.html, popup_response.html, prepopulated_fields_js.html, search_form.html and submit_line.html. The rest (for instance base_site.html, the 404 and 500 pages) are replaced project-wide in templates/admin/ only. The model name directory must be lowercase, which matters on case-sensitive file systems.

For the example the Book change list gets an extra tool next to "Add book", linking to the report from A view on one model:

A per-model override (templates/admin/catalog/book/change_list.html)
{% extends "admin/change_list.html" %}
{% block object-tools-items %}
  <li><a href="{% url 'admin:catalog_book_price_report' %}">Price report</a></li>
  {{ block.super }}
{% endblock %}

and the report page uses the admin layout with its breadcrumbs:

The report template (catalog/templates/admin/catalog/book/price_report.html)
{% extends "admin/base_site.html" %}
{% block breadcrumbs %}
<div class="breadcrumbs">
  <a href="{% url 'admin:index' %}">Home</a> &rsaquo;
  <a href="{% url 'admin:app_list' opts.app_label %}">{{ opts.app_config.verbose_name }}</a> &rsaquo;
  <a href="{% url 'admin:catalog_book_changelist' %}">{{ opts.verbose_name_plural|capfirst }}</a> &rsaquo;
  {{ title }}
</div>
{% endblock %}
{% block content %}
<p>Cheapest: {{ stats.low }} | Most expensive: {{ stats.high }} | Mean: {{ stats.mean }}</p>
{% endblock %}

A model admin can also point at a template directly through change_list_template or change_form_template, as BookAdmin does; the lookup then uses the given name first and falls back to the standard search. The change list of Book for a staff user with view_book contains "Price report", and the rendered pages carry the CSS and script links added in the next sections.

The index, login and logout pages cannot be overridden per app, because they belong to the site: the documentation’s advice is to use AdminSite.index_template, login_template and logout_template (as A site-level dashboard did for the index).

Theming and the extrabody block

Since 3.2 the admin takes its colours and fonts from CSS variables (defined in admin/css/base.css, with the dark values in admin/css/dark_mode.css, which is linked from the dark-mode-vars block since 4.1). A theme is therefore a handful of variable overrides, not a stylesheet fork. The documented route is an admin/base.html override that extends the original and adds to extrastyle; the override of the same name extending itself works because the template engine skips the template currently being rendered when resolving extends. The documentation’s version inlines a <style> element; the version below links a static file instead, which is friendlier to a Content Security Policy (see JavaScript and a Content Security Policy).

A theme through CSS variables (templates/admin/base.html)
{% extends "admin/base.html" %}
{% load static %}
{% block extrastyle %}{{ block.super }}
<link rel="stylesheet" href="{% static 'catalog/theme.css' %}">
{% endblock %}
{% block extrabody %}
  {{ block.super }}
  {% if environment and environment != "production" %}
    <div class="environment-banner">{{ environment|upper }}</div>
  {% endif %}
{% endblock %}
The variables (catalog/static/catalog/theme.css)
html[data-theme="light"], :root {
  --primary: #5b3f8c;
  --secondary: #46326c;
  --link-fg: #5b3f8c;
  --link-selected-fg: #7b57b6;
}
.environment-banner {
  position: fixed; bottom: 0; right: 0; padding: 2px 8px;
  background: var(--primary); color: #fff; font-size: 0.75rem;
}

extrabody (new in 5.2) adds content just before the closing </body> of any template that extends admin/base.html; here it carries the environment banner that each_context() supplies. The light and dark colour schemes are both honoured: dark values follow prefers-color-scheme and, since 4.2, the user can toggle the theme in the UI. The page for a Book add form and for the login page both contain the theme.css link in the scratch tests, and the add form contains the banner.

Template changes to expect when upgrading

Overrides break when the markup they copy changes. The relevant changes by version: the header block (4.0), the nav-breadcrumbs block (4.2), the dark mode variables moving to their own stylesheet and block (4.1), a reorganised delete_confirmation.html with extra blocks and scripting hooks (4.2), extrabody (5.2), the icon set switching to Font Awesome Free 6.7.2 (6.0), and in 6.1 the change forms placing each field below its label with the help text and the errors between label and input (checkboxes keep the old layout). A project that overrides change_form.html or its field partials wholesale should therefore diff against the 6.1 originals; one that only overrides blocks is mostly unaffected. In 6.1 the stock admin templates also add {% csp_nonce_attr %} to their own <script> and <link> elements, so a copied template from before 6.1 will not carry the nonce under a CSP.

JavaScript customizations

The admin ships its own scripts (including jQuery 3.7.1 in 6.1, published as django.jQuery so that it does not clash with a page’s $), and offers three supported ways to add yours.

Inline form events

When an inline row is added or removed in a change form, the admin dispatches formset:added and formset:removed events on the document. event.detail.formsetName identifies the formset (its prefix, which for an inline defaults to the related name: reviews here), and for formset:added event.target is the new row. The script goes in a static file; the change form template loads it inside the admin_change_form_document_ready block, keeping {{ block.super }} because that block already contains the admin’s own start-up code:

Loading the script (templates/admin/catalog/book/change_form.html)
{% extends "admin/change_form.html" %}
{% load static %}
{% block admin_change_form_document_ready %}
{{ block.super }}
<script src="{% static 'catalog/formset_handlers.js' %}"></script>
{% endblock %}
The handlers (catalog/static/catalog/formset_handlers.js)
// Runs on the book change form: give every newly added review row a default rating.
document.addEventListener("formset:added", (event) => {
  if (event.detail.formsetName === "reviews") {
    const rating = event.target.querySelector('input[name$="-rating"]');
    if (rating) {
      rating.value = 5;
    }
  }
});

document.addEventListener("formset:removed", (event) => {
  console.log(`A row was removed from ${event.detail.formsetName}`);
});

The handler was run under Node.js 24 against a fake document (EventTarget) and fake rows: a formset:added event for reviews set the rating input of the row to 5, an event for another formset left its row at 3, and formset:removed logged "A row was removed from reviews". The change form page for a Book contains the <script> tag, and the static file is served by runserver with text/javascript.

The code must live inside a template block (a script placed outside blocks of a template that extends change_form.html is never rendered), and {{ block.super }} must be kept. Two historical notes: the event.detail form is what Django 4.1 introduced; before it the events were jQuery events with the signature (event, $row, formsetName) and a listener had to be registered through jQuery, so code that must also support Django older than 4.1 checks event.detail.formsetName and falls back to the old arguments (the official page shows the shim). And a plain addEventListener no longer needs jQuery at all.

The Media class and django.jQuery

ModelAdmin accepts a Media inner class (or a media property), with the same css and js options as form widgets, which the admin includes on the add and change pages of that model. The Bookshelf BookAdmin above uses it for a stylesheet (css = {"all": ["catalog/admin.css"]}); the change form of Book contains the catalog/admin.css link in the tests. For scripts that depend on the admin’s jQuery, use django.jQuery on the change list and add/change views, and declare js=["admin/js/jquery.init.js", …​] when the dependency is on a form or widget Media, because the admin’s own list is not included there. The details of Media are on Form Rendering & Widgets, and static files in general on Static Files & Front-End Assets.

JavaScript and a Content Security Policy

With the built-in Content Security Policy that Django has had since 6.0, inline <script> and <style> elements are blocked unless they carry the request’s nonce. The static files used above need no nonce; the official extrabody example, which inlines an alert(), would. This is the practical reason for loading code from static files (or, when inlining is unavoidable, adding {% csp_nonce_attr %}, new in 6.1). The policy is covered on Content Security Policy.

Customizing the admin: tutorial part 7

Tutorial part 7 of the official tutorial is the guided tour of everything this page and Admin Site describe. It runs in this order, with where each step is explained:

Tutorial step What it shows Where here

Customize the admin form

fields order and fieldsets (with classes: ["collapse"])

Admin Site

Adding related objects

ChoiceInline as StackedInline, then TabularInline with extra = 3

Admin Site (the ReviewInline here is the same idea)

Customize the change list

list_display, @admin.display(boolean=True, ordering=…​, description=…​), list_filter, search_fields, built-in pagination

Admin Site

Customize the look and feel

TEMPLATES["DIRS"], copying admin/base_site.html into templates/admin/, editing the branding block

Overriding templates

Customize the index page

copying admin/index.html and replacing the app_list loop with hand-written links

A site-level dashboard, where index_template is used instead of replacing the file

The tutorial’s own comment is the best summary of the judgement involved: it edits base_site.html to teach overriding, but "in an actual project" the site_header attribute is the right tool for that particular change.

The admindocs app

django.contrib.admindocs generates browsable documentation from the docstrings of models, views, template tags and template filters of every app in INSTALLED_APPS, inside the admin and for staff only. Four steps enable it:

  1. add django.contrib.admindocs to INSTALLED_APPS;

  2. add path("admin/doc/", include("django.contrib.admindocs.urls")) to the URLconf, before the admin/ entry, otherwise the admin’s catch-all patterns swallow it;

  3. install docutils 0.22 or newer (0.23 was current when this page was checked);

  4. optionally add django.contrib.admindocs.middleware.XViewMiddleware to MIDDLEWARE, which the "Documentation for this page" bookmarklet needs.

The Bookshelf project does all four (see the INSTALLED_APPS and URLconf listings above, plus "django.contrib.admindocs.middleware.XViewMiddleware" after AuthenticationMiddleware in MIDDLEWARE). A "Documentation" link then appears at the top right of every admin page.

The pages are driven by what developers write. The Book model carries a docstring that links to another model with the :model: role and a help_text on a field; the detail view carries a docstring with a :model: role and a :template: role:

Docstrings that admindocs renders (catalog/models.py and catalog/views.py)
# catalog/models.py
class Book(models.Model):
    """A book in the catalog, written by one :model:`catalog.Author`."""

    title = models.CharField(max_length=200)
    slug = models.SlugField(unique=True)
    author = models.ForeignKey(Author, on_delete=models.PROTECT, related_name="books")
    price = models.DecimalField(max_digits=6, decimal_places=2, help_text="Price in euros.")
    published = models.DateField()

    def is_recent(self):
        """Return whether the book was published in the last 365 days."""
        ...


# catalog/views.py
def book_detail(request, slug):
    """
    Display one :model:`catalog.Book`.

    **Context**

    ``book``
        An instance of :model:`catalog.Book`.

    **Template:**

    :template:`catalog/book_detail.html`
    """
    book = get_object_or_404(Book, slug=slug)
    return render(request, "catalog/book_detail.html", {"book": book})

The supported roles are :model: (app_label.ModelName), :view: (app_label.view_name), :tag:, :filter: and :template: (a path), each with optional link text in the form :role:`link text <target>`. The models page lists fields, properties and methods with descriptions taken from help_text and method docstrings; the views page has an entry for every URL pattern; the tags and filters pages list the built-in ones and any custom ones from Custom Template Tags & Filters. A :template: role also verifies that the template exists through the template loaders and shows where it lives on disk. In the scratch project a superuser requesting /admin/doc/models/catalog.book/ received 200 with "A book in the catalog" and "Price in euros.", and /admin/doc/views/catalog.views.book_detail/ showed the view docstring.

Because admindocs exposes code structure (model fields, view names, template paths), it belongs in development and staging, or behind the same protections as the admin. Leaving it out of the production INSTALLED_APPS is a reasonable default.

Securing the admin

The admin is where the most privileged accounts log in, and its URL is the first one an attacker tries. Securing it is mostly the general security story of Security applied to one very valuable surface, in layers.

Do not expose it, or move it

The most effective measure is to not expose the admin to the internet at all: serve it on an internal host name or behind a VPN, and mount only the public site publicly. When that is not possible, two cheap measures help against drive-by scanning. They are layers, not controls, and none replaces authentication.

  • A different URL. Read the path prefix from the environment so that it does not appear in the repository. Scanners that request /admin/ then get a 404. The URLconf above uses settings.ADMIN_URL for this.

  • A network allowlist. Refuse the admin URLs for clients outside the networks that should reach them. A reverse proxy is the best place; a small middleware is a fallback or a defence in depth.

Settings for the hidden URL and the allowlist (settings.py)
import os

# The admin lives at "/<prefix>"; the default keeps development simple.
ADMIN_URL = os.environ.get("BOOKSHELF_ADMIN_URL", "admin/")

# Comma-separated CIDR networks allowed to reach the admin; empty means no restriction.
ADMIN_ALLOWED_NETWORKS = [
    cidr.strip()
    for cidr in os.environ.get("BOOKSHELF_ADMIN_NETWORKS", "").split(",")
    if cidr.strip()
]
An allowlist as function middleware (config/middleware.py)
import ipaddress

from django.conf import settings
from django.core.exceptions import PermissionDenied


def admin_allowlist(get_response):
    """Answer 403 to /admin/ and /ops/ unless the client address is on an allowed network."""
    networks = [
        ipaddress.ip_network(cidr)
        for cidr in getattr(settings, "ADMIN_ALLOWED_NETWORKS", [])
    ]
    prefixes = (f"/{settings.ADMIN_URL}", "/ops/")

    def middleware(request):
        if networks and request.path.startswith(prefixes):
            try:
                client = ipaddress.ip_address(request.META.get("REMOTE_ADDR", ""))
            except ValueError:
                raise PermissionDenied
            if not any(client in network for network in networks):
                raise PermissionDenied
        return get_response(request)

    return middleware

Source: Middleware (function-based middleware) and AdminSite objects

The middleware is listed first or second in MIDDLEWARE so that it runs before sessions and authentication do any work (see Middleware for ordering). It was run with ADMIN_ALLOWED_NETWORKS set to 10.0.0.0/8: a request with REMOTE_ADDR 203.0.113.9 received 403 on /admin/login/, 10.1.2.3 received 200, and a non-admin URL (/catalog/books/x/) was untouched. With BOOKSHELF_ADMIN_URL=staff-area/, reverse("admin:index") returned /staff-area/, the old /admin/login/ answered 404 and /staff-area/login/ answered 200.

Two cautions. REMOTE_ADDR is the address of the last proxy when Django sits behind one, so the check is only meaningful when that proxy is trusted and sets the address correctly (do not trust X-Forwarded-For blindly). And a hidden URL is hidden only from lazy scanners: it leaks through logs, browser history, referrers and shared links.

HTTPS everywhere

Everything the admin transmits is a secret: the password, the session cookie and the CSRF cookie. The deployment checklist requires site-wide HTTPS rather than HTTPS for the admin only, because the same session cookie is sent over HTTP and HTTPS for the whole site. The settings below enforce that and shorten staff sessions; the secret key and the host names come from the environment, never from the repository.

Production settings for an admin that must be safe (config/settings_prod.py)
import os

from .settings import *  # noqa: F401,F403

DEBUG = False
SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]  # never commit it
ALLOWED_HOSTS = os.environ["DJANGO_ALLOWED_HOSTS"].split(",")

# HTTPS everywhere, so the admin login form and the session cookie never travel in clear text.
SECURE_SSL_REDIRECT = True
SECURE_HSTS_SECONDS = 31536000
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True

# Staff sessions expire sooner than the two-week default.
SESSION_COOKIE_AGE = 60 * 60 * 8
SESSION_EXPIRE_AT_BROWSER_CLOSE = True

With DJANGO_SECRET_KEY generated by get_random_secret_key(), DJANGO_ALLOWED_HOSTS set and MAILERS pointing at a real backend (with the console mailer that startproject generates, Django 6.1 also reports the error mail.E001; see Getting Started), python manage.py check --deploy --settings=config.settings_prod reports a single warning, security.W021, which says that SECURE_HSTS_PRELOAD is not set (turn it on only after reading the preload list’s requirements; it is a commitment that is hard to undo). With the generated DEBUG = True, an empty ALLOWED_HOSTS and the auto-generated SECRET_KEY the same command reported security.W004, W008, W009, W012, W016, W018 and W020. Behind a TLS-terminating proxy also configure SECURE_PROXY_SSL_HEADER carefully (see Security), otherwise SECURE_SSL_REDIRECT loops.

A second factor

Django’s admin login is a password and nothing else. Brute-force protection, one-time passwords and WebAuthn come from third-party packages. Three families exist, with different integration costs:

  • django-otp provides devices (TOTP, HOTP, static recovery tokens, email) and OTPMiddleware, which adds request.user.is_verified(). Its OTPAdminSite is a ready-made AdminSite that also requires verification, with a login form that asks for the token. The ops site above uses it.

  • django-two-factor-auth offers a full login flow. Its documentation warns that the admin it patches does not enforce one-time passwords being set up for admin users, so it does not by itself make the admin two-factor.

  • django-allauth has an MFA module (TOTP, recovery codes and, when enabled, WebAuthn).

With django-otp, the requirements are the apps django_otp and the device plugins, and OTPMiddleware placed after AuthenticationMiddleware. After migrate, any OTPAdminSite (or the has_permission() below on your own site) enforces the token.

Two-factor on any site (settings.py and the has_permission override)
# settings.py
INSTALLED_APPS = [
    # ...
    "django_otp",
    "django_otp.plugins.otp_totp",
]

MIDDLEWARE = [
    # ...
    "django.contrib.auth.middleware.AuthenticationMiddleware",
    "django_otp.middleware.OTPMiddleware",
    # ...
]


# On your own site, instead of subclassing OTPAdminSite:
class BookshelfAdminSite(admin.AdminSite):
    def has_permission(self, request):
        return super().has_permission(request) and request.user.is_verified()

The ops site was tested for the two states. A staff superuser without a verified device requesting /ops/ was redirected (302) to the login page, which showed the OTP token field; after a confirmed TOTPDevice was recorded in the session (through DEVICE_ID_SESSION_KEY, as django_otp.login() does) the same request answered 200 with "Bookshelf operations", and /ops/catalog/book/add/ answered 403 because of the read-only options. Registering a device for a user is done in that site or through the django-otp admin; the page does not cover enrolment flows, and django-otp 1.7.3 declares no Django 6.1 classifier although it worked on 6.1.1 here. A brute-force limiter such as django-axes (8.3.1, classifiers up to 6.0 on 2026-10-03; not run here) is the usual complement, because a second factor does not stop password guessing against the first step.

Staff accounts, permissions and audit

  • Least privilege. is_staff only lets an account into the admin; what it can do is decided by model permissions (view, add, change, delete) granted directly or by group. Give the support team a group with view_ permissions only, and keep superusers for the people who really administer the system (see Permissions & Authorization). A superuser bypasses every check.

  • Custom views obey the same rules. Wrapping in admin_view() is not enough, as shown in Custom views: get_urls() and admin_view: check the model permission.

  • Keep the catch-all view on. final_catch_all_view = True (the default) means that any unmatched admin URL redirects an anonymous visitor to the login page, so that the response does not reveal which model URLs exist. In the tests /admin/nothing/here/ redirected to /admin/login/?next=/admin/nothing/here/ for an anonymous client.

  • Audit. Every addition, change and deletion made through the admin is recorded in LogEntry (action_time, user, content_type, object_repr, action_flag and change_message), which the index page shows as "Recent actions". Override AdminSite.get_log_entries() (5.0) to change what is listed. Admin actions that bypass the admin forms, such as bulk queryset.update(), do not log by themselves.

  • Strong passwords and short sessions. Password rules are covered on Passwords; the custom LongPasswordChangeForm of Writing the site shows how to require more of staff than of customers. The session settings above limit the exposure of a stolen cookie.

  • A custom user model. If staff authenticate differently from customers, see Custom User Model & Backends and Authentication.

Testing the customizations

Everything above is exercised with Django’s test client; each test logs a user in with force_login() and asserts on status codes, redirects, template context and rendered content. The test class below is the one that ran in the scratch project (shortened to the tests that are quoted in this page; the remaining ones check the admindocs, the branding and the catch-all as described earlier).

Tests for the site, the views and the second factor (catalog/tests.py)
from datetime import date
from decimal import Decimal

from django.contrib.auth import get_user_model
from django.contrib.auth.models import Permission
from django.test import TestCase
from django.urls import reverse
from django_otp import DEVICE_ID_SESSION_KEY
from django_otp.plugins.otp_totp.models import TOTPDevice

from .models import Author, Book


class AdminCustomizationTests(TestCase):
    @classmethod
    def setUpTestData(cls):
        User = get_user_model()
        cls.superuser = User.objects.create_superuser("root", "root@example.com", "x" * 20)
        cls.staff = User.objects.create_user("clerk", password="y" * 20, is_staff=True)
        cls.staff.user_permissions.add(Permission.objects.get(codename="view_book"))
        author = Author.objects.create(name="Ada", slug="ada")
        Book.objects.create(title="Notes", slug="notes", author=author,
                            price=Decimal("10.00"), published=date(2024, 5, 1))
        Book.objects.create(title="More", slug="more", author=author,
                            price=Decimal("20.00"), published=date(2025, 5, 1))

    def test_app_order_and_each_context(self):
        self.client.force_login(self.superuser)
        response = self.client.get(reverse("admin:index"))
        labels = [app["app_label"] for app in response.context["available_apps"]]
        self.assertEqual(labels[:2], ["catalog", "auth"])
        self.assertEqual(response.context["environment"], "development")

    def test_dashboard_requires_staff_and_permission(self):
        url = reverse("admin:dashboard")
        self.assertRedirects(self.client.get(url), "/admin/login/?next=/admin/dashboard/")
        self.client.force_login(self.staff)
        response = self.client.get(url)
        self.assertContains(response, "Books per author")
        self.assertIn("no-cache", response.headers["Cache-Control"])

    def test_ops_site_requires_verified_otp(self):
        url = reverse("ops:index")
        self.client.force_login(self.superuser)
        self.assertEqual(self.client.get(url).status_code, 302)  # staff, but not verified
        device = TOTPDevice.objects.create(user=self.superuser, name="phone", confirmed=True)
        session = self.client.session
        session[DEVICE_ID_SESSION_KEY] = device.persistent_id
        session.save()
        self.assertContains(self.client.get(url), "Bookshelf operations")
        self.assertEqual(self.client.get(reverse("ops:catalog_book_add")).status_code, 403)

The full suite (14 tests) passes with python manage.py test and python manage.py check reports no issues. Two notes on testing middleware: a factory such as admin_allowlist reads its settings when the handler is built, so a test that uses override_settings(ADMIN_ALLOWED_NETWORKS=…​) must make the request with a new Client(), as the scratch test does; and testing the second factor does not need a real authenticator, only a confirmed device and the session key.

Where the books are outdated

The four books consulted for this section, all written for Django 1.11–2.1 (Melé’s Django 2 by Example, the Greenfelds' Two Scoops of Django 1.11, Marani’s Practical Django 2 and Channels 2 and Vincent’s Django for Beginners), teach correct ideas that need adjusting today. Their admin material in particular dates from several releases ago:

  • Custom admin views. A frequently taught recipe protects a plain view with @staff_member_required and renders a hand-built template: the page works, but has no admin navigation and no each_context(). The modern recipe is get_urls() with self.admin_site.admin_view(…​) and a context built from each_context(request), as in Custom views: get_urls() and admin_view; the staff_member_required decorator remains right for plain staff-only pages (The admin is not the end-user UI).

  • url() and regular expressions in get_urls(). Older examples use url(r"^report/$", …​) and tuples; django.conf.urls.url() was removed in 4.0, so use path() and re_path().

  • short_description, boolean and admin_order_field attributes set on display functions are replaced by the @admin.display decorator (3.2); the same applies to @admin.action for actions.

  • Registering with admin.site.register(Model, ModelAdmin) still works, but @admin.register(Model) has been the idiom for years, and with a custom default site (AdminConfig.default_site) it keeps working on the custom site.

  • Replacing templates by copying them. Books copy base_site.html and index.html wholesale. Overriding blocks, using site_header, index_template and the CSS variables (3.2) avoids carrying a stale copy.

  • Admin "themes" as stylesheets. The CSS-variable theming (3.2), the dark mode (3.2) and the theme toggle (4.2) did not exist when the books were written.

  • jQuery-only formset hooks. The formset:added signature changed in 4.1 (see Inline form events); the old (event, $row, formsetName) form is obsolete.

  • Custom AdminSite with SimpleAdminConfig only. Still valid for a second site, but the default_site route (Making it the default site) keeps autodiscovery; it only arrived in Django 2.1, so most of the books do not show it.

  • One admin for everybody. Marani’s chapter on an internal dashboard builds several sites by user group; that idea stands, and what changed is that the has_permission() hook plus per-model permissions (and the view permission added in 2.1) make most such splits unnecessary.

  • Securing the admin. Two Scoops treats the admin as a tool for staff only and stresses securing it; that advice remains valid. What a book of that vintage cannot cover are the later tools (the admin’s catch-all view in 3.2, the 4.0 Cross-Origin-Opener-Policy header, django-otp’s `OTPAdminSite), and a book may suggest honeypot packages that are unmaintained (the django-admin-honeypot release on PyPI is from 2018, with classifiers up to Django 2.0).

What changed recently

Version Admin customization changes

3.0

ModelAdmin.get_inlines(); the admin’s Select2 and jQuery bundles were upgraded.

3.1

The navigation sidebar (AdminSite.enable_nav_sidebar; it needs the request context processor).

3.2

CSS-variable theming and automatic dark mode; AdminSite.final_catch_all_view; @admin.display and @admin.action.

4.0

The header block in admin/base.html; the model variable in each_context(); the sidebar quick filter. url() removed.

4.1

AdminSite.get_app_list() can reorder apps and models; dark-mode variables in their own stylesheet and block; formset:added and formset:removed events carry event.detail.

4.2

User-togglable light and dark theme; system UI fonts; the nav-breadcrumbs block; more blocks in delete_confirmation.html.

5.0

AdminSite.get_log_entries() and AdminSite.get_model_admin(); facets in change lists (see Admin Site).

5.1

list_display accepts __ lookups across relations.

5.2

The extrabody block in admin/base.html.

6.0

AdminSite.password_change_form; Font Awesome Free 6.7.2 icons; distinct icons for messages.DEBUG and messages.INFO (message_user() uses INFO, so use SUCCESS to keep the old look). The built-in Content Security Policy, which inline scripts and styles in your admin overrides must respect.

6.1

The admin login view redirects an authenticated user to next; change forms put fields below their labels (with help text and errors between label and input); list_display uses boolean icons for boolean fields on related models; the change list selects only the foreign keys named in list_display when list_select_related is False; delete_confirmation_max_display; the location and description_plural arguments of @admin.action; the wide fieldset class is gone; {% csp_nonce_attr %} is used by the stock templates.

Pitfalls

  • Forgetting admin_view(). A custom page added with a bare path() is public. Always wrap, and add a model permission check inside the view.

  • Custom URLs after the built-in ones. The built-in patterns and the catch-all swallow the path; return custom + super().get_urls().

  • No request.current_app. With several sites, {% url 'admin:…​' %} in a custom view resolves to the first registered instance, so links jump to the wrong admin.

  • Importing models at the top of the site module while the site is created in ready(). It works with default_site (the module is imported after the model registry is ready) but fails if the site module is imported from settings.py. Keep the site in a module that settings never imports.

  • Overriding change_form.html and losing {{ block.super }}. The admin’s start-up JavaScript goes with it, and prepopulated fields and related-object pop-ups stop working.

  • Putting inline <script> in an override under a CSP. The browser blocks it; use static files or nonces.

  • Leaving admindocs on in production. It lists model fields, views and template paths to every staff user.

  • Trusting a hidden URL. It is obscurity, not authentication.

  • Treating is_staff as authorization. It opens the door; permissions decide what is inside.

  • Setting final_catch_all_view = False. It restores the model-enumeration leak the view prevents.