How do you add a custom donations report page to a Django admin, under the Donation model's URLs and inside the admin layout?
answer
- extend the model admin's URLs
- order against a catch-all pattern
- the site's view wrapper
- shared context and base template
basics
~10 sOverride 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 linesfrom 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
Recall that get_urls on a ModelAdmin can add pages, and that admin_view wraps them so only staff can reach them.
Explain why custom URLs are prepended, what admin_view adds and omits, and how each_context and base_site.html give the admin layout.
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.
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