Admin Site
|
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 on this page was run on that stack in a scratch project: manage.py check, 33 Django TestCase tests
through the test client, and curl against runserver and uvicorn. They all ran on SQLite because no PostgreSQL
server was available; nothing on this page depends on a PostgreSQL feature, and the text says where a production
database changes the advice (full-text search, large tables). The admin is the part of Django that turns your models
into a ready-made back office: a change list with search, filters and bulk actions, a change form with validation, an
audit trail, and a permission system. This page covers how it is wired in, how to register models, every ModelAdmin
option and hook that matters, how the display and action decorators work, inlines, custom filters, admin permissions,
and what to watch out for. It builds the staff side of the Bookshelf application (the catalog app developed across
this section). Customising the site itself (a custom AdminSite, extra views, template overrides, JavaScript,
securing the URL) is the subject of Admin Customization.
In a hurry: what goes where
| Need | Rule of thumb | Section |
|---|---|---|
Put a model in the admin |
|
|
Choose the change-list columns |
|
|
Filter and search |
|
|
Lay out the form |
|
|
Edit related rows on the parent page |
|
|
Act on many rows |
|
|
Decide who may do what |
|
|
Keep the list fast |
|
|
Change behaviour for the whole site |
Subclass |
Where the admin sits in the request and response cycle
The admin is an ordinary Django app (django.contrib.admin) that exposes one URLconf, admin.site.urls, which you
mount wherever you like (the project template uses admin/). There is no special server: a request to /admin/…
travels through the same middleware stack as any other URL (see
Middleware and The Request/Response Cycle),
is matched by the URL resolver, and reaches a view that happens to be a method of AdminSite or ModelAdmin.
Three facts follow from that picture and explain most admin behaviour.
-
Every admin view is wrapped by
AdminSite.admin_view(). The wrapper checksAdminSite.has_permission()(the user must be active andis_staff), sends anonymous users to the admin login page with anextparameter, appliesnever_cacheandcsrf_protect. Views you add yourself must be wrapped the same way (covered in Admin Customization). -
The admin needs three middleware classes and four context processors.
SessionMiddleware,AuthenticationMiddlewareandMessageMiddlewaremust be inMIDDLEWARE; therequest,authandmessagescontext processors must be configured on aDjangoTemplatesbackend. System checksadmin.E401toadmin.E410verify all of this, somanage.py checktells you what is missing. -
Authorisation happens inside the admin view, per request. After
admin_viewlets a staff user in, eachModelAdminview askshas_view_permission(),has_add_permission(),has_change_permission()orhas_delete_permission(), and answers 403 when the answer is no. This is where the rules of Permissions in the admin plug in.
Source: The Django admin site: Overview
Enabling the admin
django-admin startproject enables the admin: django.contrib.admin and its dependencies are in INSTALLED_APPS, the
middleware and context processors are configured, and config/urls.py mounts it. For a project that was not generated
from the template, the requirements are exactly these.
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"catalog",
]
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
},
]
from django.contrib import admin
from django.urls import include, path
urlpatterns = [
path("admin/", admin.site.urls),
path("", include("catalog.urls")),
]
Create the first user with createsuperuser. Logging in to the admin requires is_staff=True; a superuser has it
and every permission. In scripts and CI the password comes from the environment instead of a prompt.
$ python manage.py migrate
$ DJANGO_SUPERUSER_PASSWORD=change-me python manage.py createsuperuser \
--noinput --username admin --email admin@example.com
Superuser created successfully.
Source: createsuperuser
A first smoke test with curl against runserver (here on port 8737) shows the permission wrapper at work: anonymous
requests are redirected to the login page, and the login page itself answers 200.
$ curl -s -o /dev/null -w "%{http_code} %{redirect_url}\n" http://127.0.0.1:8737/admin/catalog/book/
302 http://127.0.0.1:8737/admin/login/?next=/admin/catalog/book/
$ curl -s -o /dev/null -w "%{http_code}\n" "http://127.0.0.1:8737/admin/login/"
200
Source: AdminSite objects
Never leave /admin/ as the only protection of a production back office: the placement of the URL, HTTPS, throttling
and two-factor authentication are covered in Admin Customization and
Security.
The Bookshelf models
The examples use the catalogue of the running scenario: authors, books, genres and reviews. Two details matter for the
admin. Book.Meta.permissions declares a custom publish_book permission that later gates publishing and
withdrawing, and MyBook is a proxy model, a second admin page over the same table with its own permissions.
from django.conf import settings
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.urls import reverse
class Genre(models.Model):
name = models.CharField(max_length=60, unique=True)
class Meta:
ordering = ["name"]
def __str__(self):
return self.name
class Author(models.Model):
name = models.CharField(max_length=200)
slug = models.SlugField(unique=True)
country = models.CharField(max_length=2, blank=True)
class Meta:
ordering = ["name"]
def __str__(self):
return self.name
class Book(models.Model):
class Status(models.TextChoices):
DRAFT = "draft", "Draft"
PUBLISHED = "published", "Published"
WITHDRAWN = "withdrawn", "Withdrawn"
title = models.CharField(max_length=200)
subtitle = models.CharField(max_length=200, blank=True)
slug = models.SlugField(unique=True)
isbn = models.CharField(max_length=13, unique=True)
author = models.ForeignKey(Author, on_delete=models.PROTECT, related_name="books")
genres = models.ManyToManyField(Genre, blank=True, related_name="books")
price = models.DecimalField(max_digits=6, decimal_places=2)
published = models.DateField()
status = models.CharField(
max_length=10, choices=Status, default=Status.DRAFT
)
# Set by the server (the admin), never typed by an editor:
added_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="+",
)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["title"]
permissions = [("publish_book", "Can publish or withdraw books")]
def __str__(self):
return self.title
def get_absolute_url(self):
return reverse("catalog:book-detail", kwargs={"slug": self.slug})
class Review(models.Model):
book = models.ForeignKey(Book, on_delete=models.CASCADE, related_name="reviews")
reviewer = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="reviews"
)
rating = models.PositiveSmallIntegerField(
validators=[MinValueValidator(1), MaxValueValidator(5)]
)
body = models.TextField(blank=True)
class Meta:
ordering = ["id"]
constraints = [
models.UniqueConstraint(
fields=["book", "reviewer"], name="one_review_per_reader_and_book"
)
]
def __str__(self):
return f"{self.reviewer} on {self.book}: {self.rating}/5"
class MyBook(Book):
"""Proxy model: same table as Book, its own admin page and permissions."""
class Meta:
proxy = True
verbose_name = "my book"
verbose_name_plural = "my books"
Source: Models and Meta.permissions
The relevant model-side hooks for the admin are __str__ (the label used in lists, selects, breadcrumbs and the
delete confirmation), Meta.ordering, get_absolute_url() (which switches on the "View on site" link) and
verbose_name/verbose_name_plural (the names on the index page). Models are covered in
Models & Fields.
Registering models
A model appears in the admin only after it is registered. Django imports the admin module of every installed app at
start-up (autodiscover(), called by AdminConfig.ready()), so the convention is a catalog/admin.py that registers
the app’s models.
The three ways to register
from django.contrib import admin
from .models import Author, Book, Genre
# 1. Defaults: no ModelAdmin needed.
admin.site.register(Genre)
# 2. A ModelAdmin class passed explicitly.
class AuthorAdmin(admin.ModelAdmin):
search_fields = ["name"]
admin.site.register(Author, AuthorAdmin)
# 3. The decorator, the usual form. Several models may share one class.
@admin.register(Book)
class BookAdmin(admin.ModelAdmin):
list_display = ["title", "author"]
Source: ModelAdmin objects
AdminSite.register(model_or_iterable, admin_class=None, **options) also accepts keyword options that become
attributes of a generated admin class, so admin.site.register(Genre, search_fields=["name"]) is a one-line admin.
admin.register() takes one or more models and a site= keyword for a custom site. It cannot be used when the class
needs to refer to itself in __init__ (use plain super().__init__() and it works again).
Registry errors and discovery
-
Registering a model twice raises
django.contrib.admin.exceptions.AlreadyRegistered; registering an abstract model raisesImproperlyConfigured. -
admin.site.unregister(Model)removes a registration (raisingNotRegisteredwhen there is none). This is how you replace the admin of a model owned by another app, such asUser(see Custom User Model & Backends). -
admin.site.get_model_admin(Model)returns the registered admin instance (added in 5.0). -
A project that uses its own
AdminSiteinstance and imports everyadmin.pyitself should listdjango.contrib.admin.apps.SimpleAdminConfiginINSTALLED_APPSinstead ofdjango.contrib.admin, which turns auto-discovery off.
$ python manage.py shell -c "
from django.contrib import admin
from catalog.models import Book
print(type(admin.site.get_model_admin(Book)).__name__)
print(sorted(m.__name__ for m in admin.site._registry if m._meta.app_label == 'catalog'))
"
BookAdmin
['Author', 'Book', 'Genre', 'MyBook', 'Review']
Source: AdminSite methods
Two names of the admin are worth remembering because they appear in tests and templates. The URL names are
admin:<app_label>_<model_name>_<view> (changelist, add, change, delete, history), for example
admin:catalog_book_changelist, and the model permissions are <app_label>.<action>_<model_name>, for example
catalog.change_book.
ModelAdmin: the options in groups
ModelAdmin is the representation of one model in the admin. Everything is a class attribute (a static choice) with a
get_*() method twin (a choice that depends on the request or the object). The next sections walk through the options
in the order a page is built, and the complete Bookshelf admin.py is assembled piece by piece, in file order. The
file starts with its imports.
import csv
from django.contrib import admin, messages
from django.contrib.admin import ActionLocation
from django.contrib.auth import get_permission_codename
from django.db.models import Avg, Count
from django.http import HttpResponse
from django.utils import timezone
from django.utils.html import format_html
from django.utils.translation import ngettext
from .models import Author, Book, Genre, MyBook, Review
Source: ModelAdmin options
The change list: list_display and its five kinds of value
list_display sets the columns. Without it the list shows one column, the object’s __str__. Each element can be:
-
The name of a model field (a
ForeignKeyshows the related object’s__str__; aBooleanFieldshows a yes/no/unknown icon). -
A related field with the double-underscore notation, such as
author__country(Django 5.1; before that the only way was a callable). -
A callable that accepts the model instance.
-
A string naming a
ModelAdminmethod that accepts the instance. -
A string naming a model attribute or method with no required argument.
Django resolves an element in that order (a model field wins over a ModelAdmin attribute of the same name).
ManyToManyField is not supported directly because it would run one query per row; write a method that does the work
once (for example from an annotation) instead.
The decorator @admin.display (Django 3.2) configures how a callable behaves as a column or a read-only field.
| Argument | Effect |
|---|---|
|
The column header, or the label of a read-only field. Defaults to the name with underscores replaced by spaces. |
|
The field (or lookup, or query expression, or |
|
|
|
What to show when the value is |
Output of a callable is HTML-escaped. To emit markup, build it with format_html() (which escapes the arguments) and
never with string formatting plus mark_safe(): the values come from user input. A property can be a column too, but
@property must be the outer decorator and @admin.display the inner one.
Source: list_display
The Bookshelf BookAdmin combines all of this. First the change-list options.
@admin.register(Book)
class BookAdmin(admin.ModelAdmin):
# --- change list
list_display = [
"title",
"author",
"author__country",
"price",
"published",
"status",
"is_recent",
"average_rating",
]
list_display_links = ["title"]
list_editable = ["price"]
list_filter = [
"status",
"genres",
("author", admin.RelatedOnlyFieldListFilter),
"published",
RatingBandFilter,
("subtitle", admin.EmptyFieldListFilter),
]
show_facets = admin.ShowFacets.ALWAYS
search_fields = ["title", "isbn", "author__name", "price__exact"]
search_help_text = "Title, ISBN, author name or exact price"
date_hierarchy = "published"
list_select_related = ["author"]
list_per_page = 25
show_full_result_count = False
save_on_top = True
empty_value_display = "-"
Source: ModelAdmin options
And the callables the options refer to: is_recent (a boolean icon sortable by published), average_rating (an
annotated value, sortable by the annotation, with its own empty text) and review_summary (markup built with
format_html()). The annotation comes from get_queryset().
# --- queryset
def get_queryset(self, request):
return super().get_queryset(request).annotate(avg_rating=Avg("reviews__rating"))
# --- display callables
@admin.display(boolean=True, description="Recent", ordering="published")
def is_recent(self, obj):
return obj.published.year >= timezone.localdate().year - 1
@admin.display(description="Rating", ordering="avg_rating", empty_value="no reviews")
def average_rating(self, obj):
return None if obj.avg_rating is None else round(obj.avg_rating, 1)
@admin.display(description="Reviews")
def review_summary(self, obj):
if obj.pk is None:
return "-"
count = obj.reviews.count()
return format_html("<strong>{}</strong> review(s)", count)
Source: ModelAdmin.get_queryset and list_display
Related options for the list:
-
list_display_linkschooses which columns link to the change form (default: the first).Nonegives a grid without links (used by a read-only report; seeReviewWidgetAdminbelow). -
list_editableturns columns into form widgets so many rows can be edited and saved at once. A field must be inlist_display, must not be inlist_display_links, and the first column cannot be editable unless the links are moved. Breaking these rules fails the system check (admin.E122toadmin.E124). -
ordering(andget_ordering()) sets the default order and falls back to the model’sMeta.ordering. The change list always addspkto the ordering when the chosen fields do not give a total order, which can be slow on a big table without a suitable index. -
sortable_by(andget_sortable_by()) limits which columns can be sorted; an empty collection disables sorting. -
list_per_page(default 100),list_max_show_all(default 200, the largest result for which "Show all" appears),paginatorandget_paginator()control pagination.show_full_result_count(defaultTrue) controls the second number of "99 results (103 total)", which costs aCOUNT(*)of the whole table. -
empty_value_displayreplaces the default dash for empty values;AdminSite.empty_value_displaydoes it site-wide. -
preserve_filters(defaultTrue) keeps the active filters on the list after an add, change or delete. -
actions_on_top(defaultTrue),actions_on_bottom(defaultFalse) andactions_selection_counter(defaultTrue) place and decorate the actions bar.
list_select_related and the N+1 problem
A column that follows a foreign key (author here) runs one extra query per row unless the related row is fetched with
the list. list_select_related controls that: a list or tuple of field names is passed to select_related(), an empty
tuple disables it, and get_list_select_related(request) makes it dynamic. Two changes matter on 6.1: when the option
is False (the default) the change list now selects only the foreign keys named in list_display instead of all of
them, and setting the option to True (or returning True from the method) is deprecated, so name the fields.
In the scratch project with 30 books, the change list ran 11 queries both with the default and with
list_select_related = ["author"], and 36 with list_select_related = [], which is the N+1 problem in one number.
ModelAdmin ignores the option if the changelist queryset already called select_related().
Source: list_select_related and Django 6.1 release notes: admin
Filters, facets, search and date drill-down
list_filter adds the right-hand sidebar. A plain field name gives the filter that matches the field type
(BooleanField, CharField with choices, DateField, DateTimeField, IntegerField, ForeignKey,
ManyToManyField), lookups across relations work, and a tuple (field, FilterClass) picks a filter explicitly.
Custom list filters shows how to write your own. get_list_filter(request) makes the list dynamic.
Facets (Django 5.0) show the number of rows behind each filter choice, updating as filters are applied.
show_facets takes a ShowFacets value: ALLOW (the default: counts appear when the user toggles them on, which
adds _facets to the query string), ALWAYS (as in BookAdmin) and NEVER. Each filter costs extra queries, which
matters on big tables: the 30-book change list ran 11 queries with ALLOW and 35 with ALWAYS.
search_fields adds the search box. Terms are split on spaces (a quoted phrase stays together); every term must
match at least one field, and the default lookup is icontains. A field name can carry any lookup
(isbn__exact, price__exact) and can follow relations (author__name). The old one-character prefixes still
work: ^ for istartswith, = for iexact, @ for the search lookup. search_help_text (4.0)
shows a hint below the box, and get_search_fields() and get_search_results() customise it. On 6.1 a term that is
not valid for a field with an explicit lookup, such as hello for price__exact, is skipped for that field instead
of raising an error; on 5.2 drop non-text lookups from search_fields or override get_search_results().
date_hierarchy names a DateField or DateTimeField (it may follow a relation, author__pub_date) and adds drill-down
links above the list. With USE_TZ = True it relies on QuerySet.datetimes(), so the current time zone matters.
Source: ModelAdmin List Filters and search_fields
A test pins the search and filter behaviour of the Bookshelf (more tests of the same module appear with the actions and with the permissions).
class RegistrationAndChangelistTests(AdminTestBase):
def setUp(self):
self.client.force_login(self.superuser)
def test_search_and_invalid_decimal_term_is_skipped(self):
url = reverse("admin:catalog_book_changelist")
self.assertContains(self.client.get(url, {"q": "lovelace"}), "Engines")
self.assertContains(self.client.get(url, {"q": "12.50"}), "Notes")
self.assertEqual(self.client.get(url, {"q": "hello"}).status_code, 200)
def test_filters(self):
url = reverse("admin:catalog_book_changelist")
r = self.client.get(url, {"rating_band": "top"})
self.assertEqual([b.title for b in r.context["cl"].result_list], ["Engines"])
r = self.client.get(url, {"rating_band": "none"})
self.assertEqual([b.title for b in r.context["cl"].result_list], ["Notes"])
r = self.client.get(url, {"subtitle__isempty": "0"})
self.assertEqual([b.title for b in r.context["cl"].result_list], ["Engines"])
r = self.client.get(url, {"status__exact": "published"})
self.assertEqual(len(r.context["cl"].result_list), 1)
Source: Testing tools
Custom list filters
SimpleListFilter is the base class for a filter that is not a field. It needs a title (the sidebar heading) and a
parameter_name (the query-string key), a lookups(request, model_admin) method that returns (value, label) pairs
(the filter is hidden when lookups() returns nothing), and a queryset(request, queryset) method that applies
self.value(). Both methods receive the request, so a filter can depend on the user, and lookups() receives the
model_admin, so it can offer only choices that match existing rows.
class RatingBandFilter(admin.SimpleListFilter):
title = "rating"
parameter_name = "rating_band"
def _bands(self, queryset):
# Relies on the avg_rating annotation added by BookAdmin.get_queryset().
return {
"top": ("4 or better", queryset.filter(avg_rating__gte=4)),
"mixed": ("below 4", queryset.filter(avg_rating__lt=4)),
"none": ("no reviews yet", queryset.filter(avg_rating__isnull=True)),
}
def lookups(self, request, model_admin):
# Only offer the bands that match at least one visible book.
queryset = model_admin.get_queryset(request)
return [
(key, label)
for key, (label, rows) in self._bands(queryset).items()
if rows.exists()
]
def queryset(self, request, queryset):
if self.value() in ("top", "mixed", "none"):
return self._bands(queryset)[self.value()][1]
return queryset
Source: Using a SimpleListFilter
The filter above relies on the avg_rating annotation added by BookAdmin.get_queryset(). That coupling is
deliberate (one aggregate, computed once), and it is a pitfall to know: the filter stops working if the annotation is
removed.
The built-in field filters need to be opted into with a (field, FilterClass) pair. BookAdmin uses two of them:
-
admin.RelatedOnlyFieldListFilterlimits a foreign-key filter to the related rows that are actually used (only authors who have a book, instead of every author). -
admin.EmptyFieldListFilterfilters on empty or null values (books with or without a subtitle).
Other FieldListFilter subclasses are BooleanFieldListFilter, ChoicesFieldListFilter, DateFieldListFilter,
AllValuesFieldListFilter and RelatedFieldListFilter. Since 5.0 the related, choices and all-values filters accept
multi-valued query parameters, and a custom FieldListFilter that filters with the __in lookup can set
list_separator (4.1) and override expected_parameters(). A filter can set template to render itself differently,
and a filter’s has_output() decides whether it shows. GenericForeignKey fields are not supported in list_filter.
The change form: fields, layout and widgets
When neither fields nor fieldsets is set, the form shows every editable non-AutoField field in model order,
followed by the readonly_fields. Use:
-
fieldsfor simple layouts: a subset or different order; wrap names in a tuple to put them on one line ([("price", "isbn")]).excluderemoves fields from the generated form. When aModelFormand theModelAdminboth defineexclude, the admin’s wins. -
fieldsetsfor sections: a list of(title, options)pairs, whereoptionshasfields(required),classes(collapsefor an initially collapsed section) anddescription. Fieldset descriptions are not HTML-escaped, so escape user-controlled text withdjango.utils.html.escape(). Thewideclass was removed in 6.1 because the new layout made it obsolete.TabularInlinesupports only thefieldskey of a fieldset. -
readonly_fieldsfor values that are shown but not edited: model fields, model methods andModelAdminmethods (decorated with@admin.display). Read-only fields are excluded from the form, are added last whenfieldsis not set, and are ignored whenfieldsorfieldsetsis set and does not list them. -
prepopulated_fieldsmaps a slug field to its sources and fills it with JavaScript while the object is new. It does not acceptDateTimeField,ForeignKey,OneToOneFieldorManyToManyField, and it never rewrites a saved slug, so URLs do not change. -
autocomplete_fieldsreplaces the<select>of aForeignKeyorManyToManyFieldwith a Select2 box that loads options as you type. The related model’s admin must definesearch_fields, and the user needs the view or change permission on the related model, otherwise the autocomplete URL answers 403. The related admin’sget_ordering()andget_paginator()shape the results (system checksadmin.E039andadmin.E040catch the missing pieces). -
raw_id_fieldsshows a plain input for the primary key plus a lookup popup, the cheapest widget for a table with millions of rows. It needs the related model registered on the same site to show the lookup. -
radio_fieldsrenders aForeignKeyor a field withchoicesas radio buttons (admin.VERTICALoradmin.HORIZONTAL);filter_horizontalandfilter_verticalgive a many-to-many field the two-box chooser. -
formfield_overridesmaps a field class (not a string) to extra form-field arguments, such as a widget for everyTextField. It cannot change the widget of a relation field that is inraw_id_fields,radio_fieldsorautocomplete_fields. -
formswaps in your ownModelFormfor custom validation. OmitMeta.model(the admin supplies it), or setMeta.fields = [];Meta.fieldsis ignored anyway because the admin has its own way to choose fields. -
save_asreplaces "Save and add another" with "Save as new";save_as_continue=Falsesends the user to the list afterwards.save_on_toprepeats the save buttons above the form. -
view_on_sitecontrols the "View on site" link:True(the default) usesget_absolute_url(),Falsehides it, and a callable that takes the object returns any URL. -
delete_confirmation_max_display(new in 6.1) truncates the list on the delete-confirmation page, a display-only limit that is useful when deleting one object cascades to thousands. -
The
add_form_template,change_form_template,change_list_template,delete_confirmation_template,delete_selected_confirmation_template,object_history_templateandpopup_response_templateattributes point at alternative templates; the techniques are in Admin Customization.
The change form of BookAdmin uses fieldsets with a collapsed audit section, read-only fields filled by the server,
autocomplete_fields (so AuthorAdmin and GenreAdmin define search_fields) and an inline.
# --- change form
autocomplete_fields = ["author", "genres"]
prepopulated_fields = {"slug": ["title"]}
readonly_fields = ["added_by", "created_at", "review_summary"]
fieldsets = [
(None, {"fields": ["title", "subtitle", "slug", "author", "genres"]}),
("Commercial", {"fields": [("price", "isbn"), ("published", "status")]}),
(
"Audit",
{
"classes": ["collapse"],
"description": "Filled in by the server.",
"fields": ["added_by", "created_at", "review_summary"],
},
),
]
inlines = [ReviewInline]
actions = ["publish", "withdraw", "export_csv"]
The smaller options above are shown together in a second module, catalog/admin_examples.py. Each class was
registered on its own AdminSite in a test that requests the change list, the add form and the change form of every
example, and that asserts what the options render.
"""Smaller option examples; each class is registered on its own AdminSite in tests."""
from django import forms
from django.contrib import admin, messages
from django.contrib.admin import ActionLocation
from django.core import serializers
from django.db import models
from django.http import HttpResponse
from .models import Author, Book, Review
# ----------------------------------------------------------- widgets, layout
class BookLayoutAdmin(admin.ModelAdmin):
radio_fields = {"status": admin.VERTICAL} # choices field as radio buttons
filter_horizontal = ["genres"] # two-box JavaScript chooser
raw_id_fields = ["author"] # primary-key input plus lookup popup
save_as = True # "Save as new" replaces "Save and add another"
save_as_continue = False # ...and returns to the change list
actions_on_top = False
actions_on_bottom = True
actions_selection_counter = False
preserve_filters = False # forget list filters after saving
list_max_show_all = 50
sortable_by = ["title", "published"] # the other columns are not sortable
delete_confirmation_max_display = 5 # new in 6.1
def view_on_site(self, obj): # a callable instead of True/False
return "https://books.example.com" + obj.get_absolute_url()
class ReviewAdminForm(forms.ModelForm):
"""A plain ModelForm without Meta.model: the admin supplies the model."""
def clean_body(self):
body = self.cleaned_data["body"]
if self.cleaned_data.get("rating") == 1 and not body.strip():
raise forms.ValidationError("Explain a one-star rating.")
return body
def clean(self):
cleaned = super().clean()
if cleaned.get("rating") == 1 and not cleaned.get("body", "").strip():
self.add_error("body", "Explain a one-star rating.")
return cleaned
class ReviewWidgetAdmin(admin.ModelAdmin):
form = ReviewAdminForm
formfield_overrides = {
models.TextField: {"widget": forms.Textarea(attrs={"rows": 3, "cols": 60})},
}
list_display = ["book", "rating"]
list_display_links = None # a read-only grid: no row links at all
empty_value_display = "(none)"
def formfield_for_foreignkey(self, db_field, request, **kwargs):
if db_field.name == "reviewer":
from django.contrib.auth import get_user_model
kwargs["queryset"] = get_user_model().objects.filter(is_active=True)
return super().formfield_for_foreignkey(db_field, request, **kwargs)
The get_* hooks and the methods you override
Almost every option has a get_*() twin for per-request decisions: get_list_display(), get_list_display_links(),
get_list_filter(), get_list_select_related(), get_search_fields(), get_sortable_by(), get_ordering(),
get_fields(), get_fieldsets(), get_exclude(), get_readonly_fields(), get_prepopulated_fields(),
get_autocomplete_fields(), get_inlines() and get_inline_instances(). The most-used methods and what they are for:
| Method | Use it to |
|---|---|
|
Add annotations or |
|
Do something before or after saving the object, such as stamping |
|
Run extra work when one object, or the "delete selected" action’s queryset, is deleted. Same rule: you must delete. |
|
Process inline objects, for example to stamp the user; |
|
Swap the form class or choose the inlines per request or per object. |
|
Limit the choices of one field, for example to active users. |
|
Replace or extend the search; return |
|
Provide initial values for the add form (the query string also does). |
|
Decide which |
|
Flash a message with the messages framework. |
|
Change where the user lands after an add, change or delete. |
|
Add views at |
|
Wrap the whole view and pass |
A pitfall deserves its own mention: the get_*() hooks return the class attribute itself, not a copy. Appending to the
result of super().get_readonly_fields() therefore mutates the class attribute and the list grows with every request.
Always build a new list, as BookAdmin.get_readonly_fields() does below.
The second example module contains examples of the dynamic hooks.
# ------------------------------------------------------------ dynamic hooks
class BookDynamicAdmin(admin.ModelAdmin):
search_fields = ["title"]
list_display = ["title", "author", "price"]
list_filter = ["status"]
inlines = []
def get_list_display(self, request):
if request.user.has_perm("catalog.change_book"):
return [*self.list_display, "isbn"]
return self.list_display
def get_list_filter(self, request):
return [*self.list_filter, "genres"] if request.user.is_superuser else self.list_filter
def get_ordering(self, request):
return ["-published"] if request.user.is_superuser else ["title"]
def get_changeform_initial_data(self, request):
return {"status": Book.Status.DRAFT, "price": "9.99"}
def get_search_results(self, request, queryset, search_term):
queryset, may_have_duplicates = super().get_search_results(
request, queryset, search_term
)
if search_term.isdigit(): # also find a book by primary key
queryset |= self.model.objects.filter(pk=int(search_term))
return queryset, may_have_duplicates
def get_actions(self, request, action_location=ActionLocation.CHANGE_LIST):
actions = super().get_actions(request, action_location=action_location)
if not request.user.is_superuser:
actions.pop("delete_selected", None) # keys are names, values Action objects
return actions
def lookup_allowed(self, lookup, value, request):
if lookup == "added_by__username": # explicitly allow one extra lookup
return True
return super().lookup_allowed(lookup, value, request)
class ReviewInlineOnChangeAdmin(admin.ModelAdmin):
"""Show the inline only when editing, never on the add form."""
class ReviewInline(admin.StackedInline):
model = Review
fk_name = "book"
extra = 1
max_num = 5
min_num = 0
can_delete = False
show_change_link = True
classes = ["collapse"]
inlines = [ReviewInline]
def get_inlines(self, request, obj):
return self.inlines if obj is not None else []
Source: ModelAdmin methods
Inlines
An inline edits rows of a related model on the parent’s page. TabularInline (a table, compact) and StackedInline
(a block per row) differ only in the template. Both are InlineModelAdmin subclasses, so most ModelAdmin options
(fields, fieldsets, readonly_fields, raw_id_fields, autocomplete_fields, prepopulated_fields, ordering,
formfield_overrides, form, the formfield_for_* hooks and the permission methods) work in them.
| Option | Effect |
|---|---|
|
Required: the model of the inline. |
|
The foreign key to the parent. Required only when the model has more than one foreign key to the same parent. |
|
A |
|
A |
|
Extra empty forms (default 3), the maximum and the minimum number of forms. |
|
Whether rows can be deleted from the inline (default |
|
Adds a link to the row’s own change form (default |
|
Extra CSS classes ( |
|
Override the model’s names; the plural defaults to the singular plus |
|
Per-inline permissions; |
The author page edits an author’s books as a table, and the ReviewInline of the book page lets editors moderate
(edit or delete) reviews but not write new ones.
# --------------------------------------------------------------- Author
class BookInline(admin.TabularInline):
model = Book
fields = ["title", "slug", "isbn", "price", "published", "status"]
prepopulated_fields = {"slug": ["title"]}
extra = 0
show_change_link = True
ordering = ["-published"]
@admin.register(Author)
class AuthorAdmin(admin.ModelAdmin):
list_display = ["name", "country", "book_count"]
list_filter = ["country"]
ordering = ["name"] # annotate() with an aggregate ignores Meta.ordering
search_fields = ["name"] # required by BookAdmin.autocomplete_fields
prepopulated_fields = {"slug": ["name"]}
inlines = [BookInline]
def get_queryset(self, request):
return super().get_queryset(request).annotate(_book_count=Count("books"))
@admin.display(description="Books", ordering="_book_count")
def book_count(self, obj):
return obj._book_count
def save_formset(self, request, form, formset, change):
# Inlines do not go through save_model(): stamp new books here.
instances = formset.save(commit=False)
for obj in formset.deleted_objects:
obj.delete()
for instance in instances:
if isinstance(instance, Book) and instance.added_by_id is None:
instance.added_by = request.user
instance.save()
formset.save_m2m()
Source: InlineModelAdmin objects
class ReviewInline(admin.TabularInline):
model = Review
fields = ["reviewer", "rating", "body"]
raw_id_fields = ["reviewer"]
extra = 0
def has_add_permission(self, request, obj):
return False # editors moderate reviews, they do not write them
Source: InlineModelAdmin options
Points to remember when writing inlines.
-
Inlines do not go through
save_model(). The parent form is saved bysave_model(), the inline rows bysave_formset(); stamp inline objects there, asAuthorAdmin.save_formset()does. Whenformset.save(commit=False)is used, deleted objects are informset.deleted_objectsand must be deleted by hand. -
The "extra" forms are ignored when the inline has no add permission. The
ReviewInlinetest posts an extra review form and asserts that nothing is created. -
Several foreign keys to one parent need
fk_name. Without it the system check fails withadmin.E202("has no ForeignKey to…" or "has more than one ForeignKey to…"). -
Many-to-many fields can be edited as inlines on the through table. The example below edits a genre’s books with an inline on
Book.genres.through(and, on the other side of the relation, would exclude thegenresfield ofBookAdminso the field does not appear twice). Because the admin sees only a model with two foreign keys, them2m_changedsignal is not sent when rows are added or removed this way. -
A model with an intermediary
throughmodel has no widget by default; an inline on the intermediary model (withextra = 1, for example) is the way to edit its extra columns. -
Generic relations use
GenericTabularInlineorGenericStackedInlinefromdjango.contrib.contenttypes.admin. -
get_inline_instances()must return instances of the classes listed ininlines. Otherwise adding related objects answers "Bad Request".
# ---------------------------------------------------------------- Genre
class BookGenreInline(admin.TabularInline):
"""Many-to-many through table edited as an inline on the Genre page."""
model = Book.genres.through
extra = 0
autocomplete_fields = ["book"]
verbose_name = "book"
verbose_name_plural = "books"
@admin.register(Genre)
class GenreAdmin(admin.ModelAdmin):
search_fields = ["name"] # required by BookAdmin.autocomplete_fields
inlines = [BookGenreInline]
Source: Working with many-to-many models
The dynamic get_inlines() hook shows an inline only on the change view.
class ReviewInlineOnChangeAdmin(admin.ModelAdmin):
"""Show the inline only when editing, never on the add form."""
class ReviewInline(admin.StackedInline):
model = Review
fk_name = "book"
extra = 1
max_num = 5
min_num = 0
can_delete = False
show_change_link = True
classes = ["collapse"]
inlines = [ReviewInline]
def get_inlines(self, request, obj):
return self.inlines if obj is not None else []
Source: ModelAdmin.get_inlines
Actions
An action is a function called with the admin, the request and the queryset of the rows selected on the change list.
Django ships "Delete selected objects"; the actions option adds yours. Write the function (or a ModelAdmin method)
with the @admin.action decorator (Django 3.2), which sets the attributes the admin reads.
| Argument | Effect |
|---|---|
|
The label in the drop-down. It supports |
|
A list of permission names; the action is offered only if the user passes at least one check. |
|
The label used in the change list; defaults to |
|
Where the action is available: |
Key rules from the documentation:
-
Actions are listed by name in
actions: a string names aModelAdminmethod, a function reference names a function.actions = Nonedisables every action for that admin, including the site-wide ones. -
An action returns
None(the user returns to the list), or anHttpResponse: a file download, or a redirect to an intermediate view that receives the selected ids. The built-in delete action does the latter for confirmation. -
Report what happened with
self.message_user(), and handle foreseeable errors there instead of letting them become a 500. -
queryset.update()is the fast way to change many rows, but it does not callsave()and sends nopre_saveorpost_savesignals. Iterate and callsave()when those matter. -
The delete selected action calls
QuerySet.delete(), which does not call each model’sdelete()method. Overridedelete_queryset()or write your own action when you need that. -
The admin does not check object-level permissions for the selected rows. If it matters, check
self.has_change_permission(request, obj=obj)inside the action.
The BookAdmin actions: publish (available on the list and on the change form in 6.1) and withdraw, both gated by
the custom publish_book permission, plus a CSV export for anyone who can view.
# --- actions
@admin.action(
description="Publish",
description_plural="Publish selected %(verbose_name_plural)s",
permissions=["publish"],
location=[ActionLocation.CHANGE_LIST, ActionLocation.CHANGE_FORM],
)
def publish(self, request, queryset):
updated = queryset.update(status=Book.Status.PUBLISHED)
self.message_user(
request,
ngettext("%d book was published.", "%d books were published.", updated)
% updated,
messages.SUCCESS,
)
@admin.action(
description="Withdraw selected %(verbose_name_plural)s",
permissions=["publish"],
)
def withdraw(self, request, queryset):
updated = queryset.update(status=Book.Status.WITHDRAWN)
self.message_user(request, f"{updated} withdrawn.", messages.WARNING)
@admin.action(description="Export selected books as CSV", permissions=["view"])
def export_csv(self, request, queryset):
response = HttpResponse(content_type="text/csv")
response["Content-Disposition"] = 'attachment; filename="books.csv"'
writer = csv.writer(response)
writer.writerow(["isbn", "title", "author", "price", "status"])
for book in queryset.select_related("author"):
writer.writerow(
[book.isbn, _csv_safe(book.title), book.author.name, book.price, book.status]
)
return response
The CSV export protects against CSV (formula) injection: a title such as =SUM(A1) would run as a formula in a
spreadsheet, so the helper prefixes risky cells with an apostrophe.
def _csv_safe(value):
"""Stop spreadsheets from running a cell that starts like a formula."""
return f"'{value}" if value[:1] in ("=", "+", "-", "@") else value
# ----------------------------------------------------- scoping a queryset
@admin.register(MyBook)
class MyBookAdmin(BookAdmin):
def get_queryset(self, request):
return super().get_queryset(request).filter(added_by=request.user)
Source: Admin actions
An action available site-wide is registered on the site with AdminSite.add_action(function, name=None), and removed
with AdminSite.disable_action(name); admin.site.disable_action("delete_selected") removes the built-in deletion
everywhere, and listing "delete_selected" in one admin’s actions brings it back there.
ModelAdmin.get_actions(request, action_location=…) enables or disables actions per request; in 6.1 it takes the new
action_location argument and returns Action objects instead of tuples (the dynamic-hooks example above removes
delete_selected for non-superusers). The two site-wide actions below were registered on every example site.
# -------------------------------------------------------------- site-wide
@admin.action(description="Export selected objects as JSON")
def export_as_json(modeladmin, request, queryset):
response = HttpResponse(content_type="application/json")
serializers.serialize("json", queryset, stream=response)
return response
@admin.action(description="Say hello", permissions=["view"])
def say_hello(modeladmin, request, queryset):
modeladmin.message_user(request, f"{queryset.count()} selected", messages.INFO)
site = admin.AdminSite(name="example4")
site.register(Author)
site.add_action(admin_examples.export_as_json, "export_json")
site.add_action(admin_examples.say_hello)
The test below posts the change-form action the way the browser does (the form fields are CHANGE_FORM-action,
_selected_action and the submit button index; the change-list form uses action and _selected_action).
class ActionTests(AdminTestBase):
def post_action(self, action, *books):
return self.client.post(
reverse("admin:catalog_book_changelist"),
{"action": action, ACTION_CHECKBOX_NAME: [b.pk for b in books]},
follow=True,
)
def test_publish_and_withdraw_need_the_custom_permission(self):
editor = make_user("editor", "view_book", "change_book")
self.client.force_login(editor)
page = self.client.get(reverse("admin:catalog_book_changelist"))
self.assertNotContains(page, 'value="publish"')
self.assertContains(page, 'value="export_csv"')
self.post_action("publish", self.draft)
self.draft.refresh_from_db()
self.assertEqual(self.draft.status, "draft") # unknown action: ignored
def test_publisher_can_publish_in_bulk(self):
publisher = make_user("publisher", "view_book", "change_book", "publish_book")
self.client.force_login(publisher)
response = self.post_action("publish", self.draft)
self.assertContains(response, "1 book was published.")
self.draft.refresh_from_db()
self.assertEqual(self.draft.status, "published")
def test_change_form_action_for_publishers_only(self):
url = reverse("admin:catalog_book_change", args=[self.draft.pk])
self.client.force_login(make_user("editor", "view_book", "change_book"))
self.assertNotContains(self.client.get(url), 'name="CHANGE_FORM-action"')
self.client.force_login(
make_user("publisher", "view_book", "change_book", "publish_book")
)
self.assertContains(self.client.get(url), 'name="CHANGE_FORM-action"')
response = self.client.post(
url,
{"CHANGE_FORM-action": "publish", "_selected_action": self.draft.pk,
"CHANGE_FORM-select_across": "0", "index": "0"},
follow=True,
)
self.assertContains(response, "1 book was published.")
self.draft.refresh_from_db()
self.assertEqual(self.draft.status, "published")
Source: The test client
Permissions in the admin
Authorisation in the admin has four layers, from the coarsest to the finest. A staff member passes every layer that applies, and superusers pass all of them.
-
Entry:
AdminSite.has_permission(request)requiresis_activeandis_staff. A non-staff user is redirected to the login page; an anonymous user too. This check cannot be bypassed by a model permission. -
Model permissions. Each model gets
add,change,deleteand (since Django 2.1)viewpermissions whenmigrateruns (catalog.view_bookand so on), plus any inMeta.permissions(thecatalog.publish_bookof the Bookshelf). They are assigned to users or to groups. A user with only theviewpermission can open the change list and a read-only change form; thechangepermission implies the ability to view. The index page lists only the models for which the user has at least one permission, and a staff user without any permission on a model gets 403. -
Rules in code:
has_view_permission(request, obj=None),has_add_permission(request),has_change_permission(request, obj=None)andhas_delete_permission(request, obj=None). They default to the model permissions and are the place for business rules (for example, published books cannot be deleted). WhenobjisNonethe question is "in general", for the list and the "add" button.has_module_permission(request)only hides the app on the index; it does not protect the views behind it. -
Which rows:
get_queryset(request)restricts what a user sees and edits (a queryset filtered by owner, for example). It is the only layer that is about rows rather than about operations.
The BookAdmin shows three rules: the custom publish permission (used by the actions and to make status
read-only for everyone else), the rule that a published book cannot be deleted (it must be withdrawn first), and the
copy-not-mutate pattern for get_readonly_fields().
# --- permissions
def has_publish_permission(self, request):
codename = get_permission_codename("publish", self.opts) # "publish_book"
return request.user.has_perm(f"{self.opts.app_label}.{codename}")
def has_delete_permission(self, request, obj=None):
if obj is not None and obj.status == Book.Status.PUBLISHED:
return False # withdraw first, then delete
return super().has_delete_permission(request, obj)
def get_readonly_fields(self, request, obj=None):
readonly = [*super().get_readonly_fields(request, obj)] # a copy
if not self.has_publish_permission(request):
readonly.append("status")
return readonly
# --- saving
def save_model(self, request, obj, form, change):
if not change and obj.added_by_id is None:
obj.added_by = request.user
super().save_model(request, obj, form, change)
Things the admin does not do for you.
-
Object-level permissions are not visibility filters.
has_view_permission(request, obj)stops someone opening one object, but the object can still appear in the change list, in related-field choices, in autocomplete results and in the "recent actions" panel. Restrict those by customising the querysets (get_queryset(),formfield_for_foreignkey()). -
list_editable, actions and inlines have their own paths. A field made editable in the list can be changed by anyone with thechangepermission, so a "publish" rule must not rely on a field being editable in the form only (the example removesstatusfromlist_editableand makes it read-only without the permission). -
Action permissions are checked per model, not per selected object. The built-in "delete selected" action does ask
has_delete_permission(request, obj)for the selected objects (the test shows the "Cannot delete" page), but a custom action must callhas_change_permission(request, obj=obj)itself. -
The user must have a permission on the related model for autocomplete. A staff user with
view_bookbut notview_authorgets 403 from the author autocomplete. -
Superusers pass every model-permission check (
has_perm()is always true for them), but code you add inhas_*_permission()still applies to them: the "published books cannot be deleted" rule blocks superusers too, as the "delete selected" test shows. Keep superuser accounts few, and use groups for everyone else. -
The "view" permission was added in 2.1. A custom permission whose codename looks like
view_<model>accidentally grants view access to the list and detail pages since then.
Row scoping is usually done with a proxy model: the proxy has its own admin page and its own four permissions
(catalog.view_mybook, catalog.change_mybook, …), and its admin filters the queryset by the current user. The
MyBookAdmin shown with the CSV helper above does it in four lines. An object outside the queryset is not found, so
the admin answers with the redirect it uses for a missing object (the user never learns whether it exists).
Groups are the natural way to assign permissions to roles: an "Editors" group with view_book and change_book, a
"Publishers" group that adds publish_book. The tests create users with exactly the permissions they need, which is
also the best way to document and verify your policy.
def make_user(username, *codenames, staff=True):
"""A staff user holding exactly the given catalog permissions."""
user = User.objects.create_user(username, password=None, is_staff=staff)
user.user_permissions.add(*Permission.objects.filter(codename__in=codenames))
return user
class PermissionTests(AdminTestBase):
def test_non_staff_is_redirected_to_login(self):
self.client.force_login(self.reader)
response = self.client.get(reverse("admin:catalog_book_changelist"))
self.assertEqual(response.status_code, 302)
self.assertIn("/admin/login/", response["Location"])
def test_staff_without_model_permission_gets_403(self):
self.client.force_login(make_user("nobody"))
self.assertEqual(self.client.get(reverse("admin:catalog_book_changelist")).status_code, 403)
def test_view_permission_is_read_only(self):
self.client.force_login(make_user("auditor", "view_book"))
url = reverse("admin:catalog_book_change", args=[self.draft.pk])
response = self.client.get(url)
self.assertNotContains(response, 'name="_save"')
self.assertEqual(self.client.post(url, {"title": "x"}).status_code, 403)
def test_status_is_read_only_without_publish_permission(self):
self.client.force_login(make_user("editor", "view_book", "change_book"))
response = self.client.get(reverse("admin:catalog_book_change", args=[self.draft.pk]))
self.assertNotContains(response, 'name="status"')
self.client.force_login(make_user("publisher", "view_book", "change_book", "publish_book"))
response = self.client.get(reverse("admin:catalog_book_change", args=[self.draft.pk]))
self.assertContains(response, 'name="status"')
def test_published_books_have_no_delete_button(self):
self.client.force_login(make_user("deleter", "view_book", "change_book", "delete_book"))
live = self.client.get(reverse("admin:catalog_book_change", args=[self.live.pk]))
draft = self.client.get(reverse("admin:catalog_book_change", args=[self.draft.pk]))
self.assertNotContains(live, "deletelink")
self.assertContains(draft, "deletelink")
self.assertEqual(
self.client.post(reverse("admin:catalog_book_delete", args=[self.live.pk]), {"post": "yes"}).status_code,
403,
)
def test_inline_add_permission_is_removed(self):
self.client.force_login(self.superuser)
other = User.objects.create_user("other", password=None)
data = {
"title": "Engines", "subtitle": "An essay", "slug": "engines",
"author": self.author.pk, "price": "20.00", "isbn": "9780000000002",
"published": "2023-01-01", "status": "published",
"reviews-TOTAL_FORMS": "2", "reviews-INITIAL_FORMS": "1",
"reviews-MIN_NUM_FORMS": "0", "reviews-MAX_NUM_FORMS": "1000",
"reviews-0-id": Review.objects.get().pk, "reviews-0-book": self.live.pk,
"reviews-0-reviewer": self.reader.pk, "reviews-0-rating": "5",
"reviews-1-book": self.live.pk, "reviews-1-reviewer": other.pk,
"reviews-1-rating": "1",
}
self.client.post(reverse("admin:catalog_book_change", args=[self.live.pk]), data)
self.assertEqual(Review.objects.count(), 1) # the extra form was ignored
def test_group_permissions(self):
group = Group.objects.create(name="Editors")
group.permissions.set(Permission.objects.filter(codename__in=["view_book", "change_book"]))
user = User.objects.create_user("grouped", password=None, is_staff=True)
user.groups.add(group)
self.client.force_login(user)
self.assertEqual(self.client.get(reverse("admin:catalog_book_changelist")).status_code, 200)
def test_autocomplete_needs_view_or_change_on_related_model(self):
self.client.force_login(make_user("limited", "view_book"))
url = reverse("admin:autocomplete")
params = {"term": "ada", "app_label": "catalog", "model_name": "book", "field_name": "author"}
self.assertEqual(self.client.get(url, params).status_code, 403)
self.client.force_login(make_user("limited2", "view_book", "view_author"))
response = self.client.get(url, params)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["results"][0]["text"], "Ada Lovelace")
def test_my_books_scopes_rows(self):
Book.objects.filter(pk=self.draft.pk).update(added_by=self.superuser)
user = make_user("mine", "view_mybook", "change_mybook")
Book.objects.filter(pk=self.live.pk).update(added_by=user)
self.client.force_login(user)
response = self.client.get(reverse("admin:catalog_mybook_changelist"))
self.assertEqual([b.title for b in response.context["cl"].result_list], ["Engines"])
other = reverse("admin:catalog_mybook_change", args=[self.draft.pk])
self.assertEqual(self.client.get(other).status_code, 302) # "doesn't exist" redirect
Source: Default permissions and Client.force_login
Management of users and groups themselves (the UserAdmin, group forms, custom user models) is in
Custom User Model & Backends; the permission system outside the
admin (permission_required, mixins, object permissions) is in
Permissions & Authorization, and the login and password side in
Authentication and Passwords.
The audit trail: LogEntry
Every add, change and delete done through the admin writes a LogEntry (django.contrib.admin.models.LogEntry:
action_time, user, content_type, object_id, object_repr, action_flag, change_message). The "History" link
of an object lists them (paginated since 4.1) and the admin index shows the "Recent actions" of the user. A site can
filter the log shown on its index by overriding AdminSite.get_log_entries() (5.0). The log is written only by admin
views: changes made in a shell, a management command or a public view leave no entry, and a custom action that uses
queryset.update() writes no entries unless it creates them itself.
Source: LogEntry objects
Sync and async
The admin is synchronous. ModelAdmin and AdminSite methods, the formsets and the templates are all blocking code,
and there is no async variant of any hook. That is fine under ASGI: Django runs a synchronous view in a worker thread
and the admin works unchanged. Two practical consequences:
-
Write hooks as ordinary
defmethods; the admin calls them synchronously. If an action must call async code, wrap the call withasgiref.sync.async_to_sync, as the Django async documentation describes, and keep it short: the admin request holds a worker thread while it waits. -
A server that only speaks ASGI does not serve static files. Under
uvicornthe admin pages rendered (the change list answered 200) but the admin CSS and JavaScript were a 404, whereasrunserverserves them. In production serve the static files with WhiteNoise, the web server or a CDN (see Static Files & Front-end Assets).
$ uvicorn config.asgi:application --port 8737 &
$ curl -s -o /dev/null -w "login page %{http_code}\n" http://127.0.0.1:8737/admin/login/
login page 200
$ curl -s -b cookies.txt -o /dev/null -w "changelist %{http_code}\n" http://127.0.0.1:8737/admin/catalog/book/
changelist 200
$ curl -s -o /dev/null -w "static css %{http_code}\n" http://127.0.0.1:8737/static/admin/css/base.css
static css 404
Source: Asynchronous support
System checks catch configuration mistakes
Most admin mistakes are caught at start-up by the system checks (admin.E…), before any request is served. A
deliberately broken ModelAdmin shows the kind of message to expect.
bad = admin.AdminSite(name="bad")
class Broken(admin.ModelAdmin):
list_display = ["title", "pages"] # no such attribute
list_editable = ["title"] # first column, no links
autocomplete_fields = ["author"] # AuthorAdmin has no search_fields
readonly_fields = ["nope"]
list_filter = ["nada"]
fieldsets = [(None, {"fields": ["title", "title"]})]
bad.register(Book, Broken)
admin.E040 ModelAdmin must define "search_fields", because it's referenced by Broken.autocomplete_fields.
admin.E012 There are duplicate field(s) in 'fieldsets[0][1]'.
admin.E035 The value of 'readonly_fields[0]' refers to 'nope', which is not a callable, an attribute of 'Broken', or an attribute of 'catalog.Book'.
admin.E108 The value of 'list_display[1]' refers to 'pages', which is not a callable or attribute of 'Broken', or an attribute, method, or field on 'catalog.Book'.
admin.E116 The value of 'list_filter[0]' refers to 'nada', which does not refer to a Field.
admin.E124 The value of 'list_editable[0]' refers to the first field in 'list_display' ('title'), which cannot be used unless 'list_display_links' is set.
Removing MessageMiddleware and SessionMiddleware from MIDDLEWARE gives admin.E409 and admin.E410; registering
the same model twice raises AlreadyRegistered when the module is imported.
Source: System check framework: admin
Pitfalls
| Symptom | Cause | Fix |
|---|---|---|
The change list is slow and runs one query per row |
A |
Name the fields in |
The change list is slow with many filters |
Facet counts run extra queries (24 more with six filters in the scratch project), and "full result count" is a second |
|
Search is slow |
|
Search fewer fields, use |
|
|
Set |
A custom column is not sortable |
Callables are not database fields. |
|
|
|
Add the name to |
Added fields pile up request after request |
A |
Build a copy: |
|
|
Validate in the form ( |
|
Inlines are saved by |
Override |
Bulk status change leaves |
|
Loop and |
"Delete selected" does not run my |
It uses |
Override |
A user sees objects they should not in lists and dropdowns |
Object-level |
Scope |
403 on an autocomplete field |
The user has no view or change permission on the related model. |
Grant |
400 "Bad Request" when filtering by a query parameter |
|
List the field in |
|
The model is registered twice, often because another app already registers it ( |
|
Markup in a column shows as text |
The callable output is escaped. |
Return |
Admin pages unstyled in production |
Static files are not served by an ASGI or WSGI server. |
|
An error when a non-numeric term is searched on 5.2 |
A |
On 5.2 override |
Operational advice from the documentation and from practice: the admin is a tool for trusted staff, not a front end. It is a model-centric interface, and the more of your business process you force through its hooks the more you fight it. When users need a task-oriented screen, write a view instead and keep the admin for curation. See Admin Customization for the reasoning and for securing the site.
What changed recently
| Version | Admin change |
|---|---|
2.1 |
The |
3.2 |
The |
4.0 |
|
4.1 |
|
5.0 |
Facet counts ( |
5.1 |
|
5.2 |
The |
6.0 |
Font Awesome icons in the admin; |
6.1 |
Redesigned change forms (labels above fields, help text before the input); the |
Source: Django 6.1 release notes and the 6.0, 5.2, 5.1, 5.0, 4.1, 4.0, 3.2 and 2.1 release notes listed in References.
Where the books are outdated
The four books this section cross-checks were written for Django 1.11 to 2.1, and their admin chapters show their age
in four places. The first is syntax: columns are configured by setting attributes on functions (short_description,
admin_order_field, boolean) instead of @admin.display, and actions the same way instead of @admin.action. Both
forms still work, but the decorators (3.2) are the documented ones and are the only way to configure a property column.
The second is permissions: Django 1.11 and 2.0 predate the view permission (2.1), so advice that treats "can see in
the admin" and "can change" as the same thing no longer holds; a read-only auditor group is a one-permission change
today. The third is features that did not exist: list_display through related__field (5.1), facets (5.0),
AdminSite.get_model_admin() (5.0), the 6.1 action locations and ActionLocation, and the fact that allow_tags
(removed in 2.0) is gone, so markup in a column must go through format_html(). The fourth is hardening: the books'
guidance to build custom admin pages with staff_member_required still works, but AdminSite.admin_view() (shown in
Admin Customization) is the supported wrapper because it also adds
never_cache and CSRF protection, and a chapter that marks user-supplied Markdown safe with mark_safe() is an XSS
bug, not a pattern. What the books got right and still holds: the admin is for trusted staff and not an end-user UI,
list_display, list_filter, search_fields, prepopulated_fields, raw_id_fields, date_hierarchy, inlines,
admin actions and the get_urls() plus admin_view() pair for internal dashboards are all current.
Where to go next
-
Admin Customization: a custom
AdminSite,get_urls()dashboards, template overrides, JavaScript, admindocs and securing the admin. -
Custom User Model & Backends: the
UserAdminand the user forms. -
Permissions & Authorization: the permission system beyond the admin.
-
Model Forms & Formsets: inlines are model formsets.
-
Making Queries and Query Optimization & Fetch Modes: the queries behind a slow change list.
-
Security and Middleware.