Chapter 19 · The admin

19.2 The admin’s URLs: AdminSite.get_urls and AdminSite.admin_view

AdminSite.get_urls makes an admin site’s URL patterns from its registry, and AdminSite.urls gives them to a URLconf under the namespace "admin". AdminSite.admin_view wraps every view but the login: only staff get through, under the CSRF check, and uncached unless the view is wrapped as cacheable. A catch-all sends a stranger to log in whatever the path, and quote puts a primary key into a path.

The admin’s URLs depend on which models a project registers, so they are made from the registry. Its templates must reverse them by fixed names wherever the site is mounted and however many there are, and they must not tell a stranger which models are registered.

The patterns: AdminSite.get_urls

AdminSite.get_urls makes the patterns in four groups, in an order that matters, as the resolver tries them in turn: the site’s own pages; for each model in the registry, in the order of registration, its application label and model name with the model admin’s patterns included under them; one pattern for the applications’ indexes, a regular expression of the labels that have a registered model, left out when there are none; and last, a pattern that matches any path. For the harbour office’s default site:

the patterns of admin.site, and their names
""                                          index
login/                                      login
logout/                                     logout
password_change/                            password_change
password_change/done/                       password_change_done
autocomplete/                               autocomplete
jsi18n/                                     jsi18n
r/<path:content_type_id>/<path:object_id>/  view_on_site
auth/group/                                 include(GroupAdmin.urls)
auth/user/                                  include(UserAdmin.urls)
fleet/port/                                 include(PortAdmin.urls)
fleet/ship/                                 include(ShipAdmin.urls)
fleet/voyage/                               include(ModelAdmin.urls)
^(?P<app_label>auth|fleet)/$                app_list
(?P<url>.*)$                                (no name)

The list is made when the URLconf reads AdminSite.urls, from the registry as it is then, and the resolver keeps it: a model registered after the URLconf was imported answered staff with 404 until the URLconf was built again. The admin modules that register are imported as Django starts (Registering models: AdminSite.register and ModelAdmin).

AdminSite.urls and the namespace

AdminSite.urls returns a triple: the patterns, the application namespace "admin", and the site’s name as the instance namespace. That is the shape include returns, which path takes in place of a view; include itself refuses a triple with ImproperlyConfigured. A model admin’s patterns are included as a plain list, so their names stand directly in the site’s namespace, as "admin:fleet_ship_change". The default site’s name, "admin", is also the application namespace, making it the default instance among several (Namespaces).

A model admin’s patterns: ModelAdmin.get_urls

ModelAdmin.get_urls gives five views, named from the model’s application label and model name by strings such as "%s_%s_changelist": ModelAdmin.changelist_view at the empty path, ModelAdmin.add_view at add/, and ModelAdmin.history_view, ModelAdmin.delete_view and ModelAdmin.change_view at the object’s key followed by history/, delete/ and change/. The key is matched by the converter path, which takes slashes, and a sixth pattern, the key alone, which would match every path before it but the empty one, comes last. It is a RedirectView to the change page, kept, its comment says, because it was the change page’s address before Django 1.9; it answers 302 and drops the query string. ModelAdmin.urls calls get_urls.

The wrappers

AdminSite.get_urls and ModelAdmin.get_urls each have a local function, wrap, that every view in their patterns but the login goes through. The URLconf holds what it returns: a function that, at each call, passes the view through AdminSite.admin_view and calls the result. It carries the view’s attributes, copied, and marks of its own: the site’s sets "admin_site", and "login_url", the login page reversed lazily in the site’s namespace; the model admin’s sets only "model_admin".

"login_url" is for LoginRequiredMiddleware, which sends a stranger to the view’s login URL, or to LOGIN_URL when the view has none (Requiring a login: login_required, permission_required and LoginRequiredMiddleware). So under that middleware a model admin’s pages send a stranger to LOGIN_URL:

a stranger, with LoginRequiredMiddleware added to the settings
GET  /admin/              302  /admin/login/?next=/admin/
GET  /admin/fleet/ship/   302  /accounts/login/?next=/admin/fleet/ship/

AdminSite.admin_view

AdminSite.admin_view returns a function that first asks AdminSite.has_permission, true for a user who is active and staff (The site’s own pages: the index, the login and AdminSite.each_context). Anyone else gets redirect_to_login, to the site’s login page with the path asked for in "next"; but a request for the site’s logout page is redirected to the index, which then sends the user to log in with the index as "next".

Unless the view was passed with cacheable=True, never_cache goes around that function; unless the view is marked by csrf_exempt, csrf_protect does too, added here, a comment says, so that any view can be wrapped without repeating it (View decorators). The result takes the view’s name and attributes. The site wraps three views as cacheable, the password change, its done page and the JavaScript catalogue, and a model admin none: nora’s index came with headers forbidding caching, the password change page without them.

The catch-all: AdminSite.catch_all_view

The last pattern matches whatever nothing before it did, and is wrapped like the rest, so a stranger asking for any path under the site is sent to log in before the path is looked at. The documentation warns against dropping it with AdminSite.final_catch_all_view, as it “protects against a potential model enumeration privacy issue”: without it, a stranger would get 404 for an unregistered model and the login redirect for a registered one.

Staff reach AdminSite.catch_all_view, which does the work of APPEND_SLASH. CommonMiddleware cannot: it adds a slash only to a path that does not resolve, and under the site every path resolves, to the catch-all. With APPEND_SLASH true and no final slash, the view resolves the path with one, and if that finds a view not marked by no_append_slash, redirects there permanently, keeping the query string; otherwise it raises Http404. catch_all_view is itself so marked, so a path whose slashed form reaches only the catch-all gets 404.

the catch-all, for a stranger and for nora
stranger  GET  /admin/nothing/here/      302  /admin/login/?next=/admin/nothing/here/
nora      GET  /admin/nothing/here/      404
nora      GET  /admin/fleet/ship?q=end   301  /admin/fleet/ship/?q=end
nora      GET  /admin/nothing            404

Keys in paths: quote and unquote

A primary key may hold characters that mean something in a path. The admin writes a key into a URL with quote and reads it with unquote. quote replaces each of '":/_#?;@&=+$,[]<>%', the newline and the backslash with an underscore and the character’s code in two hexadecimal digits, and returns a key that is not a string unchanged; the underscore is among them, so a key’s own "_2F" comes back from unquote as it was. Its docstring says it differs from percent-encoding so that the browser does not undo it. Templates quote with the filter admin_urlquote; the views unquote the key from the path. With the office’s manifests, keyed by serial number, registered:

a manifest whose key is "M/26_7", as piet
quote('M/26_7')  ->  'M_2F26_5F7'
GET /admin/office/manifest/M_2F26_5F7/change/   200  "Change manifest"

"current_app", and two sites

The admin’s templates link with {% url %} and names in the namespace "admin", leaving the instance to the current application. The admin’s views set the request’s "current_app" to the site’s name before rendering, and URLNode.render passes it to reverse (Namespaces); where the admin reverses in Python, it passes the site’s name itself, except in LogEntry.get_admin_url, which passes none.

So a project can mount a second site. AdminSite(name="office") has its own registry, and so its own model admins, patterns, login page and instance namespace. With Ship alone registered on it, at office/:

a second site, AdminSite(name="office"), with Ship
reverse("admin:fleet_ship_change", args=(1,))                        ->  /admin/fleet/ship/1/change/
reverse("admin:fleet_ship_change", args=(1,), current_app="office")  ->  /office/fleet/ship/1/change/
stranger  GET  /office/fleet/ship/   302  /office/login/?next=/office/fleet/ship/