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 |
|
Give two audiences two admins |
Instantiate several |
|
Add a report or tool page inside the admin |
|
|
Change markup or colours |
Override templates under |
|
Run JavaScript in the admin |
Static files loaded from a |
|
Browse model, view and template docstrings |
|
|
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. |
|
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 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.
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 |
|---|---|
|
The text at the top of each page, at the end of each |
|
Target of the "View site" link; |
|
Templates for the index and the per-app index pages. |
|
The login page, the form (a subclass of |
|
Password change pages; |
|
Whether the navigation sidebar appears on large screens ( |
|
The placeholder for empty values in change lists (a dash). |
|
|
|
The dictionary of variables every admin page receives ( |
|
The apps and models for the current user, sorted alphabetically; override it to change the order on the index page (4.1). |
|
|
|
The |
|
The |
|
Extension points for custom pages, see Custom views: get_urls() and admin_view. |
|
The same operations the module-level |
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.
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.
# 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.
# 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"
# ...
]
Source: Customizing the AdminSite class
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.
# 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:
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.
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/"
Source: Reversing admin URLs
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.
-
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 appliesnever_cache()so that the page is never served stale. Skipping it exposes the page to anonymous users. -
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. -
Build the context from
each_context(request)and setrequest.current_app. The first gives the template the navigation,available_appsand the site texts; the second makes{% url 'admin:…' %}resolve inside this site when more than one exists. Useself.nameon anAdminSiteandself.admin_site.nameon aModelAdmin.
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:
{% extends "admin/base_site.html" %}
{% block breadcrumbs %}
<div class="breadcrumbs">
<a href="{% url 'admin:index' %}">Home</a> › {{ 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 %}
Source: Adding views to admin sites and ModelAdmin.get_urls()
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:
{% extends "admin/index.html" %}
{% block content %}
<p><a href="{% url 'admin:dashboard' %}">Open the catalog dashboard</a></p>
{{ block.super }}
{% endblock %}
Source: Root and login templates
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).
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)
Source: ModelAdmin.get_urls()
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 = [
{
"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",
],
},
},
]
Source: Overriding admin templates and Tutorial part 7
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.
{% 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:
{% 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:
{% extends "admin/base_site.html" %}
{% block breadcrumbs %}
<div class="breadcrumbs">
<a href="{% url 'admin:index' %}">Home</a> ›
<a href="{% url 'admin:app_list' opts.app_label %}">{{ opts.app_config.verbose_name }}</a> ›
<a href="{% url 'admin:catalog_book_changelist' %}">{{ opts.verbose_name_plural|capfirst }}</a> ›
{{ title }}
</div>
{% endblock %}
{% block content %}
<p>Cheapest: {{ stats.low }} | Most expensive: {{ stats.high }} | Mean: {{ stats.mean }}</p>
{% endblock %}
Source: Templates which may be overridden per app or model and Overriding vs. replacing an admin template
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).
{% 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 %}
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;
}
Source: Theming support and extrabody block
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:
{% extends "admin/change_form.html" %}
{% load static %}
{% block admin_change_form_document_ready %}
{{ block.super }}
<script src="{% static 'catalog/formset_handlers.js' %}"></script>
{% endblock %}
// 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.
Source: ModelAdmin asset definitions and jQuery
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 |
|
|
Adding related objects |
|
Admin Site (the |
Customize the change list |
|
|
Customize the look and feel |
|
|
Customize the index page |
copying |
A site-level dashboard, where |
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:
-
add
django.contrib.admindocstoINSTALLED_APPS; -
add
path("admin/doc/", include("django.contrib.admindocs.urls"))to the URLconf, before theadmin/entry, otherwise the admin’s catch-all patterns swallow it; -
install
docutils0.22 or newer (0.23 was current when this page was checked); -
optionally add
django.contrib.admindocs.middleware.XViewMiddlewaretoMIDDLEWARE, 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:
# 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 usessettings.ADMIN_URLfor 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.
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()
]
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.
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
Source: Deployment checklist: HTTPS and SESSION_COOKIE_AGE
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-otpprovides devices (TOTP, HOTP, static recovery tokens, email) andOTPMiddleware, which addsrequest.user.is_verified(). ItsOTPAdminSiteis a ready-madeAdminSitethat also requires verification, with a login form that asks for the token. Theopssite above uses it. -
django-two-factor-authoffers 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-allauthhas 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.
# 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_staffonly 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 withview_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_flagandchange_message), which the index page shows as "Recent actions". OverrideAdminSite.get_log_entries()(5.0) to change what is listed. Admin actions that bypass the admin forms, such as bulkqueryset.update(), do not log by themselves. -
Strong passwords and short sessions. Password rules are covered on Passwords; the custom
LongPasswordChangeFormof 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.
Source: LogEntry objects and AdminSite.final_catch_all_view
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).
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)
Source: Testing tools and Writing and running tests
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_requiredand renders a hand-built template: the page works, but has no admin navigation and noeach_context(). The modern recipe isget_urls()withself.admin_site.admin_view(…)and a context built fromeach_context(request), as in Custom views: get_urls() and admin_view; thestaff_member_requireddecorator remains right for plain staff-only pages (The admin is not the end-user UI). -
url()and regular expressions inget_urls(). Older examples useurl(r"^report/$", …)and tuples;django.conf.urls.url()was removed in 4.0, so usepath()andre_path(). -
short_description,booleanandadmin_order_fieldattributes set on display functions are replaced by the@admin.displaydecorator (3.2); the same applies to@admin.actionfor 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.htmlandindex.htmlwholesale. Overriding blocks, usingsite_header,index_templateand 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:addedsignature changed in 4.1 (see Inline form events); the old(event, $row, formsetName)form is obsolete. -
Custom
AdminSitewithSimpleAdminConfigonly. Still valid for a second site, but thedefault_siteroute (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 theviewpermission 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 (thedjango-admin-honeypotrelease on PyPI is from 2018, with classifiers up to Django 2.0).
What changed recently
| Version | Admin customization changes |
|---|---|
3.0 |
|
3.1 |
The navigation sidebar ( |
3.2 |
CSS-variable theming and automatic dark mode; |
4.0 |
The |
4.1 |
|
4.2 |
User-togglable light and dark theme; system UI fonts; the |
5.0 |
|
5.1 |
|
5.2 |
The |
6.0 |
|
6.1 |
The admin login view redirects an authenticated user to |
Pitfalls
-
Forgetting
admin_view(). A custom page added with a barepath()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 withdefault_site(the module is imported after the model registry is ready) but fails if the site module is imported fromsettings.py. Keep the site in a module that settings never imports. -
Overriding
change_form.htmland 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_staffas 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.
References
-
Django 2 by Example (Packt) (latest edition: Django 5 By Example)