Skip to content

Commit 308b9ee

Browse files
feat(api): surface ModelAdmin custom views to the SPA (link-out) (#539)
Many consumers add bespoke admin pages — reports, import/export, per-object tools — via ModelAdmin.get_urls(). The SPA cannot reach them. Introspect get_urls(), drop the five standard CRUD routes plus the unnamed legacy catch-all, and surface every remaining named route as {name, label, url, level}. Object-level routes (an object_id/pk capture group) reverse with the object's pk; changelist-level routes reverse with no args, all through the admin site's namespace. Detail payload gains object- + changelist-level custom_views; the registry model entry gains changelist-level ones only. The key is omitted when empty. Everything is guarded — a misbehaving consumer get_urls degrades to [] and never 500s. No new permission surface: this rides the existing detail/registry staff + has_view_permission gates. Frontend: DetailPage renders an unobtrusive link-out (a single button, or a "More" dropdown for several) of real <a target="_blank"> anchors to the Django-rendered pages. Contract gains a CustomView type. This is Option A (link-out), the design-safe foundation; the same payload powers a future iframe/native approach. Reverse only resolves while the legacy admin remains mounted. Refs #439 Co-authored-by: Martin Castro Laminrs <mcastro@laminr.ai>
1 parent 743473e commit 308b9ee

8 files changed

Lines changed: 658 additions & 4 deletions

File tree

django_admin_react/api/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ the design.
2626
| `permissions.py` | Staff + AdminSite.has_permission gate; per-op delegation. |
2727
| `registry.py` | AdminSite introspection helpers. |
2828
| `serializers.py` | Conservative field serialization + denylist. |
29+
| `custom_views.py` | Surface a ModelAdmin's custom `get_urls()` routes (#439). |
2930
| `views/` | One module per endpoint. |
3031

3132
Implementation status is tracked in `../README.md`.
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
"""Surface a ``ModelAdmin``'s *custom* admin views to the SPA (Issue #439).
2+
3+
Many consumers add bespoke admin pages — report / import-export /
4+
dashboard pages and per-object tool views — by overriding
5+
``ModelAdmin.get_urls()`` (or ``AdminSite.get_urls()``). The SPA cannot
6+
render those Django-templated pages itself, but it can **link out** to
7+
them (Option A): a real ``<a target="_blank">`` to the legacy
8+
admin-rendered page. This module is the design-safe foundation — the
9+
same ``{name, label, url, level}`` payload also powers a future iframe
10+
or native approach.
11+
12+
How custom routes are distinguished from the standard CRUD set
13+
----------------------------------------------------------------
14+
15+
Django's ``ModelAdmin.get_urls`` always emits five *named* routes for a
16+
model — ``<app>_<model>_{changelist,add,change,delete,history}`` — plus
17+
one unnamed catch-all (the legacy ``<pk>/`` redirect, ``name=None``).
18+
Anything a consumer adds on top has a different (or no) standard suffix.
19+
We compute the standard names from ``model._meta`` and treat every
20+
remaining *named* route as a custom view. Unnamed routes are skipped:
21+
without a name they can't be ``reverse()``-d through the admin
22+
namespace, so the SPA could never link to them anyway.
23+
24+
How URLs are reversed
25+
---------------------
26+
27+
Custom admin routes live under the **admin site's** URL namespace
28+
(``admin_site.name``, e.g. ``"admin"``). We reverse each route as
29+
``reverse(f"{admin_site.name}:{name}", args=[...])`` — ``args=[obj.pk]``
30+
for an object-level route, ``args=[]`` for a changelist-level one. Every
31+
reverse is guarded; an un-reversible route (legacy admin not mounted,
32+
extra capture groups we don't fill, a consumer ``reverse`` quirk) is
33+
silently skipped rather than raising.
34+
35+
Hardening (``SECURITY.md`` §3): this module introduces **no new
36+
permission surface**. It is only ever called from the detail / registry
37+
views, *after* their staff + ``has_view_permission`` gates have run, and
38+
it never reads object data beyond the ``pk`` it is handed. A misbehaving
39+
consumer ``get_urls`` must never 500 a response — every introspection
40+
step degrades to ``[]``.
41+
"""
42+
43+
from __future__ import annotations
44+
45+
from typing import Any
46+
47+
from django.contrib.admin.options import ModelAdmin
48+
from django.db.models import Model
49+
from django.urls import reverse
50+
from django.utils.text import capfirst
51+
52+
# Capture-group names Django uses for the per-object segment of a
53+
# ``ModelAdmin`` route. If a custom route's regex captures any of these
54+
# (or, defensively, a generic ``pk``), it is an *object-level* view that
55+
# needs an object id to reverse.
56+
_OBJECT_ID_GROUPS: frozenset[str] = frozenset({"object_id", "pk"})
57+
58+
59+
def _standard_route_names(model: type[Model]) -> frozenset[str]:
60+
"""The five route names Django's ``ModelAdmin.get_urls`` always emits.
61+
62+
Built from ``model._meta`` exactly the way Django builds them in
63+
``ModelAdmin.get_urls`` (``info = app_label, model_name``), so this
64+
stays correct for any model without depending on Django internals.
65+
"""
66+
info = f"{model._meta.app_label}_{model._meta.model_name}"
67+
return frozenset(
68+
{
69+
f"{info}_changelist",
70+
f"{info}_add",
71+
f"{info}_change",
72+
f"{info}_delete",
73+
f"{info}_history",
74+
}
75+
)
76+
77+
78+
def _is_object_level(pattern: Any) -> bool:
79+
"""``True`` if the URL pattern captures an object id.
80+
81+
Inspects the compiled regex's named capture groups for an
82+
``object_id`` / ``pk`` group — the marker that the route is a
83+
per-object tool view (e.g. ``<pk>/make-report/``) rather than a
84+
page that stands alone (e.g. ``import/``). Any introspection error
85+
is treated as "not object-level" (changelist-level) so we still
86+
attempt a no-args reverse.
87+
"""
88+
try:
89+
groups = pattern.regex.groupindex.keys()
90+
except Exception:
91+
return False
92+
return any(group in _OBJECT_ID_GROUPS for group in groups)
93+
94+
95+
def _label_for_route(entry: Any, name: str) -> str:
96+
"""Human-readable label for a custom route.
97+
98+
Prefers a ``short_description`` on the view callable (Django's own
99+
convention for naming admin callables); falls back to humanising the
100+
route name — ``capfirst(name.replace("_", " "))``. The model/app
101+
prefix that ``get_urls`` bakes into the name (``<app>_<model>_``) is
102+
stripped first so the label reads as the *action* (e.g.
103+
``"Send report"``) rather than the wiring.
104+
"""
105+
callback = getattr(entry, "callback", None)
106+
short = getattr(callback, "short_description", None)
107+
if short:
108+
return str(short)
109+
return str(capfirst(name.replace("_", " ")))
110+
111+
112+
def _reverse_or_none(admin_site: Any, name: str, args: list[Any]) -> str | None:
113+
"""Reverse ``<admin_site.name>:<name>`` with ``args``, or ``None``.
114+
115+
Guards every reverse: an un-reversible route (legacy admin not
116+
mounted, a route needing more args than we supply, a custom
117+
namespace quirk) degrades to ``None`` and is dropped by the caller.
118+
"""
119+
site_name = getattr(admin_site, "name", None)
120+
if not site_name:
121+
return None
122+
try:
123+
return reverse(f"{site_name}:{name}", args=args)
124+
except Exception:
125+
return None
126+
127+
128+
def custom_views_for(
129+
model_admin: ModelAdmin,
130+
admin_site: Any,
131+
*,
132+
obj: Model | None = None,
133+
) -> list[dict[str, Any]]:
134+
"""Custom (non-CRUD) admin views for a ``ModelAdmin`` (Issue #439).
135+
136+
Walks ``model_admin.get_urls()``, drops the five standard CRUD
137+
routes (and any unnamed route), and returns a descriptor per
138+
remaining custom route::
139+
140+
{"name": str, "label": str, "url": str, "level": "object"|"changelist"}
141+
142+
Reversal rules:
143+
144+
- ``level == "object"`` routes need an object id. When ``obj`` is
145+
provided they are reversed with ``args=[obj.pk]``; when ``obj`` is
146+
``None`` (the registry / changelist context) they are skipped —
147+
there is no object to point them at.
148+
- ``level == "changelist"`` routes are reversed with ``args=[]``.
149+
150+
Returns ``[]`` when the admin exposes no custom routes, or when none
151+
of them reverse. NEVER raises: a misbehaving consumer ``get_urls``
152+
degrades to ``[]`` (so the caller's detail / registry response is
153+
unaffected).
154+
"""
155+
try:
156+
urls = model_admin.get_urls()
157+
except Exception:
158+
return []
159+
160+
try:
161+
standard = _standard_route_names(model_admin.model)
162+
except Exception:
163+
return []
164+
165+
out: list[dict[str, Any]] = []
166+
for entry in urls:
167+
descriptor = _descriptor_for_route(entry, standard, admin_site, obj)
168+
if descriptor is not None:
169+
out.append(descriptor)
170+
return out
171+
172+
173+
def _descriptor_for_route(
174+
entry: Any,
175+
standard: frozenset[str],
176+
admin_site: Any,
177+
obj: Model | None,
178+
) -> dict[str, Any] | None:
179+
"""Build one custom-view descriptor, or ``None`` to skip the route.
180+
181+
A route is skipped (``None``) when it is unnamed, is a standard CRUD
182+
route, is object-level but we have no object, fails to reverse, or
183+
raises during introspection. One bad route never sinks the rest:
184+
every failure degrades to ``None`` rather than propagating.
185+
"""
186+
try:
187+
name = getattr(entry, "name", None)
188+
# Unnamed routes (Django's legacy ``<pk>/`` catch-all) and the
189+
# five standard CRUD routes are not "custom views".
190+
if not name or name in standard:
191+
return None
192+
193+
pattern = getattr(entry, "pattern", None)
194+
object_level = _is_object_level(pattern) if pattern is not None else False
195+
196+
if object_level:
197+
if obj is None:
198+
# No object to anchor the route to in this context.
199+
return None
200+
url = _reverse_or_none(admin_site, name, [obj.pk])
201+
level = "object"
202+
else:
203+
url = _reverse_or_none(admin_site, name, [])
204+
level = "changelist"
205+
206+
if url is None:
207+
return None
208+
209+
return {
210+
"name": str(name),
211+
"label": _label_for_route(entry, str(name)),
212+
"url": url,
213+
"level": level,
214+
}
215+
except Exception:
216+
return None

django_admin_react/api/registry.py

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
from django.http import HttpRequest
1818
from django.utils.module_loading import import_string
1919

20+
from django_admin_react.api.custom_views import custom_views_for
21+
2022

2123
def get_admin_site() -> AdminSite:
2224
"""Resolve the configured admin site instance.
@@ -70,23 +72,38 @@ def _model_permissions(model_admin: ModelAdmin, request: HttpRequest) -> dict[st
7072
}
7173

7274

73-
def _model_entry(model: type[Model], model_admin: ModelAdmin, request: HttpRequest) -> dict:
75+
def _model_entry(
76+
model: type[Model],
77+
model_admin: ModelAdmin,
78+
request: HttpRequest,
79+
admin_site: AdminSite,
80+
) -> dict:
7481
"""Single ``models[]`` element for the registry response.
7582
7683
Wire shape is documented in ``docs/api-contract.md`` §2. Only
7784
metadata + the four ``has_*_permission`` booleans go on the wire;
7885
no model field schemas, no row counts — those are detail/list
7986
endpoint responsibilities.
87+
88+
Changelist-level custom views (Issue #439) are attached when the
89+
consumer's ``ModelAdmin.get_urls`` exposes any — so the SPA can link
90+
to a model-wide report / import page from the list/home. Object-level
91+
custom views are *not* surfaced here (no object to anchor them to);
92+
those live on the detail payload. The key is omitted when empty.
8093
"""
8194
meta = model._meta
82-
return {
95+
entry = {
8396
"app_label": meta.app_label,
8497
"model_name": meta.model_name,
8598
"object_name": meta.object_name,
8699
"verbose_name": str(meta.verbose_name),
87100
"verbose_name_plural": str(meta.verbose_name_plural),
88101
"permissions": _model_permissions(model_admin, request),
89102
}
103+
extra_views = custom_views_for(model_admin, admin_site, obj=None)
104+
if extra_views:
105+
entry["custom_views"] = extra_views
106+
return entry
90107

91108

92109
def _user_payload(request: HttpRequest) -> dict:
@@ -195,7 +212,7 @@ def build_registry_payload(admin_site: AdminSite, request: HttpRequest) -> dict:
195212
# ``SECURITY.md`` §3).
196213
if not model_admin.has_view_permission(request):
197214
continue
198-
entry = _model_entry(model, model_admin, request)
215+
entry = _model_entry(model, model_admin, request, admin_site)
199216
entry["real_app_label"] = model._meta.app_label
200217
entry["app_label"] = group_label
201218
models_payload.append(entry)

django_admin_react/api/views/detail.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
from django.http import JsonResponse
3535
from django.views.generic import View
3636

37+
from django_admin_react.api.custom_views import custom_views_for
3738
from django_admin_react.api.inlines import inlines_payload
3839
from django_admin_react.api.permissions import forbidden_response
3940
from django_admin_react.api.permissions import is_admin_user
@@ -122,7 +123,7 @@ def _build_payload(
122123
) -> dict[str, Any]:
123124
"""Compose the full detail response body (contract §4)."""
124125
visible_names = _visible_field_names(model_admin, request, obj)
125-
return {
126+
payload: dict[str, Any] = {
126127
"app_label": model._meta.app_label,
127128
"model_name": model._meta.model_name,
128129
"pk": obj.pk,
@@ -140,6 +141,16 @@ def _build_payload(
140141
# it a plain string on the wire (it's a SafeString in Django).
141142
"empty_value_display": str(model_admin.get_empty_value_display()),
142143
}
144+
# Custom admin views (Issue #439): link-outs to the consumer's bespoke
145+
# admin pages reached via ``ModelAdmin.get_urls()``. Object-level routes
146+
# are reversed with this object's pk; changelist-level routes (simple,
147+
# no-arg) are included too so the SPA can offer them from the detail
148+
# toolbar. Only attached when non-empty so older clients and plain
149+
# admins see no extra key.
150+
extra_views = custom_views_for(model_admin, admin_site, obj=obj)
151+
if extra_views:
152+
payload["custom_views"] = extra_views
153+
return payload
143154

144155

145156
def _view_on_site_url(model_admin: ModelAdmin, obj: Model) -> str | None:

0 commit comments

Comments
 (0)