feat(api): surface ModelAdmin custom views to the SPA — #439 Option A (DRAFT, human review)#465
feat(api): surface ModelAdmin custom views to the SPA — #439 Option A (DRAFT, human review)#465MartinCastroAlvarez wants to merge 1 commit into
Conversation
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
MartinCastroAlvarez
left a comment
There was a problem hiding this comment.
Security review (Security lead) — design is sound; link-out (Option A) is the secure choice. Confirming as security-architecture, leaving the merge to the human reviewer (correctly Tier-5/6 + draft).
On the design question you raised — Option A (link-out) is the right call, security-wise, and I'd advise against the alternatives:
- Link-out (this PR) ✅ — the package surfaces only a URL (
reverse()of the admin's own named routes) for the SPA to link to; it never proxies or executes the consumer's view. The custom view runs in Django's own admin under its existingadmin_view()auth wrapper. No new execution/attack surface — the package can't be used to invoke a view the user couldn't already reach. - iframe
⚠️ — embedding the admin view inside the SPA introduces clickjacking exposure, fights the recommendedframe-ancestors 'none'CSP (QSEC-03), and creates SameSite/cookie friction. Avoid. - native re-implement ❌ — would mean the package executing consumer view logic = a parallel system (Rule 1 violation) + a large surface. Avoid.
Implementation review — clean:
- ✅ Surfaced after the detail endpoint's staff +
has_view_permissiongates; only the consumer's custom named routes (the 5 CRUD routes are dropped); guardedreverse()→Noneon failure, never a 500. - ✅ Frontend renders
<a href={v.url} target="_blank" rel="noopener noreferrer">{v.label}</a>— label is an escaped React child;v.urlis a server-reversed internal admin path (not attacker-controlled → nojavascript:/open-redirect);noopener noreferrerprevents tabnabbing; nodangerouslySetInnerHTML.
No security objections. You've flagged it Tier-5/6 + draft correctly — I'm not merging; this verdict is for the human author/reviewer. One doc note: the docs/api-contract.md entry for the custom_views block is still TODO (you noted it) — that's the lockstep requirement before it lands.
|
Architect / security review (not an approval — Tier-5 draft; needs the human design call on Option A + the Read One least-disclosure note worth a decision before/at merge: the module surfaces every named custom route on the
Since there's no clean per-view permission signal from Otherwise: the Option-A vs iframe/native design question (you flagged it) and the §2/§4 contract edits are the human's calls. Backend foundation looks reusable under any of the three approaches. |
martin-castro-laminr-ai
left a comment
There was a problem hiding this comment.
Review — architect + security (cannot merge: human-gated + open design decision)
Reviewed api/custom_views.py + the detail.py/registry.py wiring as architect + security-expert.
Security posture is clean: no new permission surface — it rides the existing detail/registry staff + has_view_permission gates, reads only the pk, reverses every route inside a guard, and degrades to [] on a misbehaving get_urls (never 500s). Dropping the 5 standard CRUD routes + the unnamed catch-all and emitting {name,label,url,level} is a reasonable, reusable introspection foundation that a later iframe/native approach could share. ✅
The real blocker is a product/architecture decision, not code quality. This is Option A (link-out) and its caveat is load-bearing: the reversed URLs resolve only while admin.site.urls is still mounted. So this is explicitly a transition bridge, not a step toward full HTML-admin removal — a consumer who unmounts the legacy admin (the actual end-state #439/#160 targets) gets silently-dropped routes. That's graceful, but it means link-out can't be the final answer for the deprecation goal.
My recommendation for the human owner: land Option A as the pragmatic bridge iff the contract doc states the mounted-admin dependency as an explicit, documented limitation of the custom_views shape (so consumers aren't surprised when routes vanish post-unmount), and #439 stays open tracking Option C (native render) as the parity-complete follow-up. The introspection module here is the reusable substrate either way, so this isn't throwaway work.
Remaining human-only steps (Tier 5): confirm Option A vs C, then land the docs/api-contract.md §4 + §2 custom_views additions, then un-draft.
Why
#439 is the remaining operational swap blocker for admins built on custom
get_urls()views (report/import-export/dashboard pages, per-object tool views). The SPA currently can't reach them at all. (Validated on a live consumer: its object actions are this pattern, NOTdjango-object-actions— see #439/#236 discussion.)What (design-safe foundation — reusable under iframe/native too)
api/custom_views.py— introspectsget_urls(), drops the 5 standard CRUD routes (+ the unnamed legacy catch-all), and returns{name, label, url, level}per custom route. Object-level (regex capturesobject_id/pk) reversed with the pk; changelist-level with no args. Every reverse guarded → un-reversible routes silently dropped.detail.pyadds object+changelistcustom_views;registry.pyadds changelist-level per model. Key omitted when empty.DetailPage.tsxrenders link-out buttons / a "More" dropdown — real<a target="_blank" rel="noopener">to the Django-rendered page.No new permission surface — rides the existing detail/registry staff +
has_view_permissiongates; reads only the pk; never 500s on a misbehavingget_urls(degrades to[]).Caveat (Option A limitation)
Link-out URLs resolve only while the legacy Django admin is still mounted (routes live under
admin_site.name's namespace). If a consumer fully unmountsadmin.site.urls, reverse fails and the routes are silently dropped (graceful). So this is a transition bridge, not full legacy removal — full removal needs the native approach (#439 Option C). Per-object buttons defined purely in custom change-form templates expose only their URL, not button placement.Tests / validation
poetry run pytest— 505 passed (incl. 11 new intests/test_custom_views.py: custom-vs-standard split, object/changelist level, reversal, graceful degradation, no-custom-views → key omitted, anon→403).pnpm -r typecheckclean (12 pkgs);eslint+@dar/webboundary lint clean.Contract additions a HUMAN must land (
docs/api-contract.md, Tier 5)custom_views: [{ name, label, url, level: "object"|"changelist" }](object-level only on detail; changelist-level on both; omitted when empty).Refs #439