skip to content

Branding & URL Overrides

Subclassing AdminSite rebrands the admin or runs a second one, template overrides reskin pages, and get_urls adds custom views. Interviewers probe keeping it fast on million-row tables.

part ofDjangooverview, primer and where to startread it →
on this pageshow

explore

questions

6

How do you add a custom donations report page to a Django admin, under the Donation model's URLs and inside the admin layout?

level: middleimportance: must knowfreq 48%

answer

  1. extend the model admin's URLs
  2. order against a catch-all pattern
  3. the site's view wrapper
  4. shared context and base template

basics

~10 s

Override ModelAdmin.get_urls() and prepend a path whose view is wrapped in self.admin_site.admin_view(); render a template extending admin/base_site.html with admin_site.each_context(request). admin_view only checks staff access, so check model permissions inside the view.

solid answer

~40 s

`ModelAdmin.get_urls()` returns the model's URL patterns, so override it and return `custom + super().get_urls()` — **prepended**, because the admin's `<path:object_id>/` pattern would otherwise swallow `report/` as an object id. Wrap the view with `self.admin_site.admin_view()`, which redirects anyone failing `AdminSite.has_permission()` (active staff) to the admin login, applies `never_cache` unless you pass `cacheable=True`, and adds CSRF protection. It does **not** check model permissions, so call `self.has_view_permission(request)` yourself and query through `self.get_queryset(request)` to keep row scoping. Build the context from `self.admin_site.each_context(request)` plus `opts` and `title`, set `request.current_app = self.admin_site.name`, and render a template extending `admin/base_site.html`. Name the path like `donations_donation_report` so you can reverse it as `admin:donations_donation_report`, and link it from an overridden `change_list.html`.

code

python · 43 lines
python
from django.contrib import admin
from django.core.exceptions import PermissionDenied
from django.db.models import Sum
from django.db.models.functions import TruncMonth
from django.template.response import TemplateResponse
from django.urls import path

from .models import Donation


@admin.register(Donation)
class DonationAdmin(admin.ModelAdmin):
    list_display = ["donor", "amount", "received_at", "campaign"]

    def get_urls(self):
        info = self.opts.app_label, self.opts.model_name
        custom = [
            path(
                "report/",
                self.admin_site.admin_view(self.report_view),
                name="%s_%s_report" % info,
            ),
        ]
        return custom + super().get_urls()  # before the <path:object_id>/ catch-all

    def report_view(self, request):
        if not self.has_view_permission(request):
            raise PermissionDenied
        rows = (
            self.get_queryset(request)
            .annotate(month=TruncMonth("received_at"))
            .values("month")
            .annotate(total=Sum("amount"))
            .order_by("month")
        )
        context = {
            **self.admin_site.each_context(request),
            "opts": self.opts,
            "title": "Donations by month",
            "rows": rows,
        }
        request.current_app = self.admin_site.name
        return TemplateResponse(request, "admin/donations/donation/report.html", context)

go deeper

for a junior

Recall that get_urls on a ModelAdmin can add pages, and that admin_view wraps them so only staff can reach them.

for a middle

Explain why custom URLs are prepended, what admin_view adds and omits, and how each_context and base_site.html give the admin layout.

for a senior

Enforce model permissions and get_queryset scoping in the view, keep heavy reports off the request path, and set current_app for multi-site setups.

for a principal

Decide when reports belong in the admin at all versus a dedicated reporting tool with its own access model and data pipeline.

## What you are building A charity's staff want a **"Donations by month"** page next to the Donation change list: same header, same navigation, same login — but a view you write. The admin supports this directly; the work is in wiring it the way the admin's own views are wired. ## Step 1: add the URL Every `ModelAdmin` builds its URLs in **`get_urls()`**, mounted under `/<admin root>/<app_label>/<model_name>/`. The default list contains the change list (`""`), `add/`, `<path:object_id>/history/`, `<path:object_id>/delete/`, `<path:object_id>/change/` and a final `<path:object_id>/` that redirects old-style URLs to the change view. That last pattern is **permissive**: `report/` matches it with `object_id="report"`. So: 1. Call `super().get_urls()`. 2. Build your own list, e.g. `path("report/", ..., name="donations_donation_report")`. 3. Return **`custom + urls`**, never `urls + custom`. Appending instead of prepending produces a confusing failure: `/admin/donations/donation/report/` redirects to `.../report/change/`, the lookup fails, and the user lands on the admin index with "Donation with ID 'report' doesn't exist". ## Step 2: wrap the view `self.admin_site.admin_view(view, cacheable=False)` does three things: - calls `AdminSite.has_permission(request)` — by default active **and** staff — and redirects to the admin login otherwise; - applies `never_cache` unless `cacheable=True`; - applies `csrf_protect` unless the view is marked CSRF-exempt. What it does **not** do is check any model permission. A staff member with no rights on donations could open the report unless the view checks `self.has_view_permission(request)` and raises `PermissionDenied`. Likewise, query through `self.get_queryset(request)` rather than `Donation.objects` so any row scoping on the `ModelAdmin` applies to the report as well. ## Step 3: render inside the admin layout - Start the context with **`self.admin_site.each_context(request)`**: it supplies `site_header`, `site_title`, `site_url`, `has_permission`, `available_apps` (the navigation sidebar) and recent log entries. - Add `opts` (the model's `_meta`, used by breadcrumbs), a `title`, and your data. - Set **`request.current_app = self.admin_site.name`** so `{% url 'admin:...' %}` tags resolve against the right admin instance when there is more than one. - Return a `TemplateResponse` for a template that `{% extends "admin/base_site.html" %}` and fills `content` (and `breadcrumbs`). ## Step 4: link to it Override `admin/<app_label>/<model_name>/change_list.html`, extend `admin/change_list.html`, and add an `<li>` to the `object-tools-items` block after `{{ block.super }}`, pointing at `{% url 'admin:donations_donation_report' %}`. The button then sits next to "Add donation". ## `ModelAdmin.get_urls()` versus `AdminSite.get_urls()` | Hook | Mounted at | Good for | |---|---|---| | `ModelAdmin.get_urls()` | `/admin/<app>/<model>/...` | Pages about one model: reports, imports, bulk tools | | `AdminSite.get_urls()` on a subclass | `/admin/...` | Site-wide pages: a dashboard across models | Both use `admin_view()` the same way. ## Pitfalls 1. **Unwrapped view** — a plain view under the admin prefix is reachable by anyone logged in, or anyone at all. 2. **Caching a personalised page** by passing `cacheable=True` without thinking about per-user data. 3. **Heavy aggregation on every hit** — for a large table, precompute the report or cache it; the admin will not. 4. **Forgetting `current_app`** — links in the template point to the default site when several exist. ## Testing the report page Custom admin views need the same tests as any view, plus the admin-specific gates: 1. **Anonymous user** — GET the report URL and assert a redirect to the admin login with `next` set to the report. 2. **Staff without donation rights** — assert a 403, proving the `has_view_permission()` check runs. 3. **Staff with the view permission** — assert a 200, the report template, and the admin header text in the page. 4. **Scoped staff** — if `get_queryset()` limits rows, assert the totals only include their rows. 5. **URL order** — reverse `admin:donations_donation_report` and assert it resolves to the report view, not the change view's redirect. These tests catch the two regressions that happen most: someone reorders `get_urls()`, or someone removes the permission check while "simplifying" the view. ## Keeping the report fast Aggregations over the whole donations table run on every page load. Restrict the date range by default, add the indexes the grouping needs, or cache the computed rows for a few minutes with the low-level cache. Passing `cacheable=True` to `admin_view()` is not a substitute: it only drops the `never_cache` headers and caches nothing itself.

  • What happens if a Django ModelAdmin appends its custom report/ path after super().get_urls() instead of before?
    The admin's final pattern, `<path:object_id>/`, matches `report/` first and redirects to `report/change/`. The change view then fails to find a Donation with id "report" and sends the user to the admin index with a "doesn't exist" warning. Prepending the custom patterns avoids it.
  • Does wrapping a view in Django's admin_site.admin_view() check that the user may view donations?
    No. It only calls `AdminSite.has_permission()`, which by default means active staff, plus `never_cache` and `csrf_protect`. Model-level rights must be checked in the view — for example `self.has_view_permission(request)` — and rows should come from `self.get_queryset(request)` so scoping rules apply.
  • How do you put a link to the custom report next to 'Add donation' on the Django admin change list?
    Create `templates/admin/donations/donation/change_list.html` that extends `admin/change_list.html`, override the `object-tools-items` block, keep `{{ block.super }}`, and add an `<li>` linking to `{% url 'admin:donations_donation_report' %}`. Only that model's change list gets the button.

saying these in an interview costs you the question

  • admin_view checks the model's view permission automatically
  • Custom paths can be appended after the admin's own URLs
  • Any view under the /admin/ prefix is protected by the admin login
  • each_context is optional; base_site.html renders the same without it
  • Querying Donation.objects directly respects the ModelAdmin's row scoping
open as a page

A Django admin change list over a 20-million-row donations table takes seconds per page; which admin options and overrides make it fast?

level: seniorimportance: must knowfreq 42%

basics

~20 s

A Django admin change list runs two COUNT queries per page by default. Set show_full_result_count = False to drop the unfiltered one, supply a paginator whose count is estimated or capped, and keep search, sorting, date_hierarchy and filters on indexed, cheap paths.

open as a page

How do you replace the Django admin's 'Django administration' header and page titles with a charity's own branding?

level: juniorimportance: should knowfreq 50%

basics

~10 s

Set site_header, site_title and index_title on the Django admin site, either on admin.site directly or on an AdminSite subclass made the default through an AdminConfig subclass's default_site. Logos and colours come from overriding admin/base_site.html.

open as a page

When would you run two Django admin sites, such as one for charity staff and one for volunteers, and how do you wire them?

level: middleimportance: should knowfreq 28%

basics

~20 s

Create a second AdminSite instance with its own name, register the models it should show on it explicitly, and mount its urls at another path. The name is its URL instance namespace; permissions still come from the same users and groups.

open as a page

How do you override a Django admin template for one model only, and which admin templates can only be overridden project-wide?

level: middleimportance: should knowfreq 40%

basics

~10 s

Put the template at templates/admin/<app_label>/<model_name>/<name>.html, extend the admin original and override one block. Only a listed set, such as change_form.html and change_list.html, is looked up per model; base_site.html and others are project-wide only.

open as a page

What does django.contrib.admindocs add to the Django admin, and what must you configure to enable it?

level: middleimportance: nice to knowfreq 15%

basics

~20 s

django.contrib.admindocs builds staff-only documentation pages from the docstrings of models, views, template tags and filters. Enable it by adding the app, including its URLs before the admin's, and installing docutils; the admin then shows a Documentation link.

open as a page