Part VI · Users and the admin

Chapter 19: The admin

django.contrib.admin builds a working site for editing a project’s rows out of the models themselves. An AdminSite keeps a ModelAdmin for each model registered with it and turns them into a URLconf; each ModelAdmin serves its model’s list, add, change, delete and history pages, built from the model’s _meta and the model admin’s options, and asks the user’s permissions before each.

What the admin has to do is easy to state. Given the models of a project and nothing else, it must give the project’s staff a page that lists each model’s rows and can search, filter and sort them, a form to add a row and one to change it, a way to delete a row and see what goes with it, and a record of who changed what; and it must show each user only what that user is allowed to do. A model already says most of what that takes. Its _meta names its fields and their types, which say what form field each needs; its verbose names give the pages their titles; its relations say what a delete would take with it; and the auth application has, by default, made four permissions for it, to add, change, delete and view. What the model does not say, which columns a list shows or which fields can be searched, a project says in a model admin’s options, class attributes such as list_display and search_fields.

So the admin is mostly glue. It writes no SQL, parses no forms and checks no passwords of its own: its URLs are a URLconf, its forms are model forms and inline formsets, its lists are querysets and a Paginator, its pages are templates, its users and permissions are the auth application’s, and its notices are messages. What it adds is the decisions: which form, which fields, which permission, which template, and in what order a save is done. Those decisions are the chapter.

The objects

Table 19.1 The objects of the admin. All of them are in django/contrib/admin/; django/contrib/admin/options.py holds the model admins and their views, django/contrib/admin/sites.py the site.
object what it is when it acts
AdminSite what it is an admin site: its registry of model admins, its URLs, and the pages that are not about one model (the index, the login, the logout) when it acts at registration; when its URLs are read; on every request to it
site what it is the default admin site, an instance of DefaultAdminSite that makes the real site at its first use when it acts when anything uses it
ModelAdmin what it is a model admin: one model’s options and the views that use them when it acts made once, when its model is registered; then on every request for that model’s pages
InlineModelAdmin what it is an inline: a related model’s rows edited on the parent’s form, as an inline formset when it acts made for each request to a change form
AdminSite.admin_view what it is the wrapper around every page but the login: lets only staff in, active users whose is_staff is true; turns caching off, but for three of the site’s pages, and the CSRF check on when it acts on every request
ChangeList what it is the change list: the filtered, searched, sorted and paginated queryset of one model’s list page when it acts made by ModelAdmin.changelist_view for each request
ModelAdmin.changeform_view what it is the change form: the add page and the change page, which are one view when it acts on each add or change page
AdminForm, InlineAdminFormSet what it is wrappers that walk a form or a formset in fieldsets for the templates when it acts while a change form renders
delete_selected what it is the action the site gives every model admin, offered to users who may delete; an action is a function run on the objects selected in a change list or, since 6.1, on a change form’s object when it acts when a change list is posted with an action
LogEntry what it is a row of the log: who added, changed or deleted which object, and what changed when it acts written after the change form saves its object or an editable list a row, and before the delete page or delete_selected deletes; another action logs only what it logs itself

The harbour office’s admin

The harbour office keeps its ports, ships and voyages in the admin. Its settings install the admin with the applications startproject writes beside it but the static files, four of the middleware startproject writes, and a template engine that finds templates in the applications’ directories, where the admin keeps its own:

# harbour/settings.py
INSTALLED_APPS = [
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "harbour.fleet",
    "harbour.crew",
    "harbour.office",
]
MIDDLEWARE = [
    "django.contrib.sessions.middleware.SessionMiddleware",
    "django.middleware.csrf.CsrfViewMiddleware",
    "django.contrib.auth.middleware.AuthenticationMiddleware",
    "django.contrib.messages.middleware.MessageMiddleware",
]
TEMPLATES = [
    {
        "BACKEND": "django.template.backends.django.DjangoTemplates",
        "APP_DIRS": True,
        "OPTIONS": {
            "context_processors": [
                "django.template.context_processors.request",
                "django.contrib.auth.context_processors.auth",
                "django.contrib.messages.context_processors.messages",
            ],
        },
    }
]

The URLconf puts the default site under admin/:

# harbour/urls.py
from django.contrib import admin
from django.urls import path

urlpatterns = [
    path("admin/", admin.site.urls),
]

And the fleet application says what its models look like in the admin:

# harbour/fleet/admin.py
from django.contrib import admin

from harbour.fleet.models import Port, Ship, Voyage


class ShipInline(admin.TabularInline):
    model = Ship
    fields = ["name", "tonnage"]
    extra = 1


@admin.register(Port)
class PortAdmin(admin.ModelAdmin):
    list_display = ["name", "country"]
    search_fields = ["name"]
    inlines = [ShipInline]


@admin.register(Ship)
class ShipAdmin(admin.ModelAdmin):
    list_display = ["name", "tonnage", "home", "is_large"]
    list_filter = ["home"]
    search_fields = ["name", "home__name"]
    actions = ["home_at_valletta"]

    @admin.display(boolean=True, ordering="tonnage", description="Large")
    def is_large(self, obj):
        return obj.tonnage > 300

    @admin.action(permissions=["change"], description="Home the selected ships at Valletta")
    def home_at_valletta(self, request, queryset):
        valletta = Port.objects.get(name="Valletta")
        count = queryset.update(home=valletta)
        self.message_user(request, "%d ships now home at Valletta." % count)


admin.site.register(Voyage)

The office has two ports, Valletta and Piraeus; two ships, Endeavour and Beagle, both of Valletta; and two voyages of Endeavour. It has three users. piet is a superuser. nora is staff, and in the group Clerks, which may view ports and ships and change ships but not add or delete either, and has no permission on voyages. tomas is not staff. One browser, driven by Django’s test client, which keeps cookies as a browser does, made these requests as nora:

Table 19.2 Nora’s visit to the admin. Every response but the 403 carried headers that forbid caching it; the 403 was made from an exception after it had left the admin’s wrapper (How an exception becomes a response).
request response SQL sent
"GET" /admin/, with no cookie response 302 to /admin/login/?next=/admin/ SQL sent none
"POST" /admin/login/?next=/admin/, nora’s username and password response 302 to /admin/ SQL sent the user, by username; whether a new session key is taken; the session’s row inserted; the user’s last login updated; the session’s row updated
"GET" /admin/ response 200, “Site administration”, listing Ports with a link to view them and Ships with a link to change them SQL sent the session’s row; the user; the user’s own permissions; the permissions of the user’s groups; the log
"GET" /admin/fleet/ship/ response 200, “Select ship to change”: Beagle, Endeavour SQL sent the session’s row; the user; the permissions, two queries; the ports, for the filter; the ships counted, twice; the ships, each with its port
"GET" /admin/fleet/ship/1/change/ response 200, “Change ship” SQL sent the session’s row; the user; the ship; the permissions; the ship model’s content type; the ports and the sailors, for the two selects
"POST" /admin/fleet/ship/1/change/, tonnage 368 response 302 to /admin/fleet/ship/ SQL sent the session’s row; the user; then, in one transaction: the ship; the permissions; the port, by its key, and again that it exists; the check constraint, in a savepoint; the unique constraint; the ship updated; a row of the log inserted
"GET" /admin/fleet/ship/ response 200, with “The ship “Endeavour” was changed successfully.” above the list SQL sent as before
"GET" /admin/fleet/ship/1/history/ response 200, “Change history: Endeavour”, one row: nora, “Changed Tonnage.” SQL sent the session’s row; the user; the ship; the permissions; the log’s rows for the ship, counted, then read with their users and content types
"GET" /admin/fleet/port/1/change/ response 200, “View port”, the port’s ships listed under it SQL sent the session’s row; the user; the port; the permissions; the port’s ships; the port model’s content type
"GET" /admin/fleet/voyage/ response 403 SQL sent the session’s row; the user; the permissions
"POST" /admin/logout/ response 200, “Logged out” SQL sent the session’s row; the user; the permissions; the session’s row again, and its delete

The first request found no session, so no user, and the admin answered it, as it ordinarily answers a request for one of its pages from someone who is not staff, with a redirect to its own login page, keeping the page asked for in "next" (The admin’s URLs: AdminSite.get_urls and AdminSite.admin_view has the exceptions). The login is the auth application’s LoginView, given the admin’s own form, AdminAuthenticationForm, which refuses a user who is not staff (The login and password views: LoginView, LogoutView and PasswordChangeView).

The index lists, application by application, every registered model on which the user has some permission, and nothing else: auth’s users and groups, and the voyages, are registered too, and nora saw none of them. Ports came with a link to view and ships with a link to change, because nora may change ships and only view ports (The site’s own pages: the index, the login and AdminSite.each_context). The change list sent its own queries after the permissions: the ports, which the filter on home offers as its choices; the ships counted twice, once with the filters and the search applied, for the “2 ships” the page shows, and once without, for the total a search’s results are given beside, “1 result (2 total)”; and the page of ships, with each ship’s port joined in, because home is a column (The change list: changelist_view and ChangeList).

Once the session and the user had been read, the "POST" that changed the tonnage did all its work in one transaction. It loaded the ship, checked that nora may change it, built a model form for it, and ran the form’s validation, which looked the port up by its key and checked that it exists, then checked the two constraints of the ship model’s Meta: a tonnage above nought, and no other ship of that name at that port. Then it saved the ship, wrote a row of the log saying that the tonnage had changed, and answered with a redirect to the change list, which showed the message. The port’s change form answered with “View port”, every field read-only and no button to save, because nora may view ports but not change them; and the voyages answered 403, because nora has no permission on them at all.

The site and its registry

A model is in the admin because something registered it with a site. AdminSite.register takes a model, or several, and a model admin class, and keeps an instance of that class, made with the model and the site, in the site’s registry; with no class it uses ModelAdmin itself, and with keyword arguments it first makes a subclass that has them as attributes. It refuses an abstract model, a model with a composite primary key, and a model already registered; a swapped model it passes over in silence. The decorator django.contrib.admin.register calls it for the class it decorates.

The registering is done by the project’s own modules, and the admin’s default config makes sure they are imported. AdminConfig.ready, the admin application’s ready unless a project installs SimpleAdminConfig instead, calls autodiscover, which imports the module admin of every installed application that has one, in the order of INSTALLED_APPS (Importing by name: import_string and autodiscover_modules). That is how harbour/fleet/admin.py ran, and how, before it, the auth application’s own admin module registered users and groups. The site they register with, site, is a lazy object that, the first time it is used, calls what the admin application’s config names in default_site, the class AdminSite unless a project’s config names another.

A model admin is made once, at registration, and serves every request for its model from then on, for every user, in every thread. So it holds the model, the model’s _meta and the site, and its options, and no state of a request: everything that depends on the request is passed to its methods, and many options have a hook, a method named after the option with “get” before it, that takes the request (get_list_display, get_fieldsets), which a project overrides to decide for each request (Registering models: AdminSite.register and ModelAdmin).

AdminSite.urls turns the registry into a URLconf. It is a triple, as include returns one: the site’s patterns, the application namespace "admin", and the site’s name as the instance namespace, "admin" for the default site. The patterns are the site’s own pages, then, for each registered model, its application label and model name with the model admin’s patterns included under them, then one pattern for the index of every application with a registered model, and last, unless AdminSite.final_catch_all_view is false, a pattern that catches every other path:

Figure 19.1 The default site of the harbour office: the registry, and the URLconf AdminSite.urls makes of it. Each model admin’s views are named after the model’s application label and model name, under the namespace "admin".

Every view of that URLconf but the login goes through AdminSite.admin_view. The wrapper asks AdminSite.has_permission, which is true for a user who is active and staff and asks no permission of any model, and sends anyone else to the login page, or from the logout page to the index; and it adds never_cache unless the view was wrapped as cacheable, and csrf_protect unless the view is exempt from the CSRF check. The catch-all pattern is wrapped too, so that a stranger asking for any path under admin/ is sent to the login page, whether the path names a model or not; the documentation gives the purpose, which is not to let a stranger find out which models are registered (The admin’s URLs: AdminSite.get_urls and AdminSite.admin_view).

A model admin’s pages

Being staff only lets a user in. What a user may do once in is asked of the model admin, page by page, by four methods: BaseModelAdmin.has_add_permission, has_change_permission, has_delete_permission and has_view_permission. Each asks the user’s has_perm for the permission the auth application made for the model, "fleet.change_ship", and has_view_permission is true for a user who may view or may change. The last three take the object in question as well, and ignore it unless a project overrides them to decide object by object (Permissions in the admin: has_change_permission and the others). The change list and a change page need view or change; the add page, add; saving a change, change; the delete page, delete. A user who may view and not change gets the change form with every field read-only, as nora did for Valletta.

The change form

The add page and the change page are one view, ModelAdmin.changeform_view, which runs a request whose method is not safe inside a transaction on the database the router gives for writing the model. For a change it loads the object with ModelAdmin.get_object, through the model admin’s get_queryset, and, once the user’s permission to view or change is confirmed, sends the user back to the index with a warning when there is none. It asks get_fieldsets which fields to show and in what groups, and get_form for a model form class with those fields, the read-only ones left out, each form field the form class does not declare made from its model field by formfield_for_dbfield, which is where the admin’s widgets come in (The admin’s forms: get_form and formfield_for_dbfield, The admin’s widgets: related objects, raw ids and autocomplete). Each inline the user has some permission on adds an inline formset (Inlines: InlineModelAdmin and its formsets).

A "POST" is then saved in an order that a project can step into at each point: the form is validated, and save_form makes the object without saving it; the formsets are validated; if all are valid, save_model saves the object; save_related saves its many-to-many data and then each formset; construct_change_message describes what changed; log_addition or log_change writes the log; and response_add or response_change adds the message and chooses the redirect by the button that was pressed, or, in a popup, answers with a page that hands the object back to the window that opened it. A form that does not validate is shown again with its errors, and nothing is saved (The change form: changeform_view).

The change list

ModelAdmin.changelist_view makes a ChangeList, which reads the query string and builds the queryset in a fixed order: every parameter that is not one of the change list’s own (the page, the search, the ordering and a few more) is first put to BaseModelAdmin.lookup_allowed, which ordinarily refuses a lookup that follows a relation past the related model’s key unless a filter or the date hierarchy offers it; each filter of list_filter takes the parameters it understands and, ordinarily, narrows the queryset by them; what parameters are left are applied as lookups; by default, the foreign keys named in list_display are joined; the ordering is set, from the model admin, the model or the column the user clicked, with the primary key added when that ordering does not pin down every row; the search of search_fields is applied; and the result is made distinct if a field’s filter, a lookup or the search crossed a relation that can repeat a row. Then it counts the rows and takes one page of them. The columns, the filters and the search each have a section: The change list’s columns: list_display and list_editable and Filters and search: list_filter, search_fields and date_hierarchy.

Actions, deleting and the log

The change list is also a form. Ticking rows and choosing an action posts the selected keys and the action’s name back to the change list, which calls the action with the queryset of the selected rows, or of every row the list matches when “Select all” was chosen (Actions: response_action and delete_selected). The site gives every model admin delete_selected, offered only to a user who may delete, unless the model admin sets actions to None, or the site disables it and the model admin does not name it; ShipAdmin adds home_at_valletta, which only a user who may change ships is offered, as its permissions asks. Deleting, by the action or from an object’s own delete page, first shows what a delete would take with it, found by a subclass of the collector that a delete itself uses, and refuses when a protected relation or a missing permission stands in the way (Deleting: delete_view and get_deleted_objects).

The admin’s views write a LogEntry for each object they add, change or delete, though not for the objects an inline saves with it or a delete takes with it, nor for an action’s work unless the action logs it, as delete_selected does: the user, the content type and key of the object, the object’s text as it was, and what changed, kept as JSON with the fields’ labels untranslated, and translated when it is read, where the catalogues have the label as it was kept. The history page of an object reads those rows back (The log and the history: LogEntry and history_view). The row nora’s change wrote:

object_repr     Endeavour
action_flag     2   (CHANGE)
change_message  [{"changed": {"fields": ["Tonnage"]}}]
str(entry)      Changed “Endeavour” — Changed Tonnage.

The pages themselves

A model admin’s page is ordinarily a TemplateResponse of a template the view chooses from a list, unless the model admin names a template of its own: the model’s, the application’s, then the admin’s, so that a project can change one model’s change form by putting a template at "admin/fleet/ship/change_form.html" in its own templates, which the loaders find before the admin’s. The context begins, on every page but the one that closes a popup and the change list’s page for a database error, with what AdminSite.each_context gives: the site’s titles, whether the user is staff, the applications and models the user may see, for the navigation, and the log (The admin’s templates and media). A view sets the request’s "current_app" to the site’s name, so that the URLs its templates reverse lead into the same site when a project has several; the links of the index’s recent actions, which a log entry reverses for itself, do not.

The admin needs contenttypes, auth and messages installed, a Django template engine with the messages context processor and, with the default authentication backend, the auth one, and the session, authentication and message middleware; the system checks say so when one is missing, and check the options of each model admin whose class is not ModelAdmin itself against its model (The admin’s checks: check_dependencies and ModelAdminChecks). A sister application, django.contrib.admindocs, adds pages, which its documentation has a project include beside the admin’s, that document the project’s models, views, template tags and filters from their docstrings, and say which directories hold a template of a given name (The documentation generator: django.contrib.admindocs).

Where it meets the rest of Django

The admin’s URLs are a URLconf with a namespace (Namespaces), and its views are function views wrapped in csrf_protect and, most of them, never_cache (View decorators). Its users, its login, its password change and its permissions are the auth application’s (Authentication, sessions and messages): AdminSite.has_permission reads is_staff, and a model’s pages ask has_perm. Its forms are model forms and inline formsets (Model forms: ModelForm and fields_for_model, Model formsets and inline formsets), with widgets and media of its own (Form assets: Media). Its lists are querysets (QuerySets) cut into pages by a Paginator (Pagination: Paginator and Page), and its delete pages walk relations with a subclass of the collector that deleting uses (Deleting: the Collector). Its pages are templates (Templates), its notices messages, and its JavaScript is translated by a JavaScriptCatalog (The language views: set_language and JavaScriptCatalog). The log names objects by their content types, and the default “View on site” link goes through the content types’ shortcut; the admin’s CSS and JavaScript are static files (Content types, static files and the other contrib apps).