Skip to content

feat(api): surface ModelAdmin custom views to the SPA — #439 Option A (DRAFT, human review)#465

Closed
MartinCastroAlvarez wants to merge 1 commit into
mainfrom
feat/custom-views-439-clean
Closed

feat(api): surface ModelAdmin custom views to the SPA — #439 Option A (DRAFT, human review)#465
MartinCastroAlvarez wants to merge 1 commit into
mainfrom
feat/custom-views-439-clean

Conversation

@MartinCastroAlvarez
Copy link
Copy Markdown
Owner

⚠️ TIER 5/6 — HUMAN REVIEW REQUIRED. DO NOT AUTO-MERGE. Adds a wire-contract shape → reserved for a human author/reviewer (CLAUDE.md §3). Draft. Needs the docs/api-contract.md edits below (intentionally not made here). Also a design decision: this is Option A (link-out) from the #439 discussion — please confirm it's the desired approach (vs iframe / native) before landing.

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, NOT django-object-actions — see #439/#236 discussion.)

What (design-safe foundation — reusable under iframe/native too)

  • api/custom_views.py — introspects get_urls(), drops the 5 standard CRUD routes (+ the unnamed legacy catch-all), and returns {name, label, url, level} per custom route. Object-level (regex captures object_id/pk) reversed with the pk; changelist-level with no args. Every reverse guarded → un-reversible routes silently dropped.
  • detail.py adds object+changelist custom_views; registry.py adds changelist-level per model. Key omitted when empty.
  • DetailPage.tsx renders 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_permission gates; reads only the pk; never 500s on a misbehaving get_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 unmounts admin.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 pytest505 passed (incl. 11 new in tests/test_custom_views.py: custom-vs-standard split, object/changelist level, reversal, graceful degradation, no-custom-views → key omitted, anon→403).
  • pnpm -r typecheck clean (12 pkgs); eslint + @dar/web boundary lint clean.

Contract additions a HUMAN must land (docs/api-contract.md, Tier 5)

  • §4 detail response + §2 registry model entry: optional custom_views: [{ name, label, url, level: "object"|"changelist" }] (object-level only on detail; changelist-level on both; omitted when empty).

Refs #439

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
Copy link
Copy Markdown
Owner Author

@MartinCastroAlvarez MartinCastroAlvarez left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 existing admin_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 recommended frame-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_permission gates; only the consumer's custom named routes (the 5 CRUD routes are dropped); guarded reverse()None on 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.url is a server-reversed internal admin path (not attacker-controlled → no javascript:/open-redirect); noopener noreferrer prevents tabnabbing; no dangerouslySetInnerHTML.

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.

@MartinCastroAlvarez
Copy link
Copy Markdown
Owner Author

Architect / security review (not an approval — Tier-5 draft; needs the human design call on Option A + the docs/api-contract.md edits).

Read api/custom_views.py end-to-end. The core is safe: it's only called after the detail/registry staff + has_view_permission gates, reads only the pk, reverses solely under the admin site's own namespace (so the URLs are same-origin admin routes — no open-redirect surface), and every reverse is guarded so a misbehaving get_urls degrades to [] rather than 500ing. Standard-route exclusion built from model._meta (not Django internals) is correct. No privilege escalation: the target Django views enforce their own permissions on click.

One least-disclosure note worth a decision before/at merge: the module surfaces every named custom route on the ModelAdmin, with no per-view permission filtering. get_urls() doesn't carry per-view permission metadata (it lives inside the view fn), so a user who can view the model sees link-out buttons for tool views they may not be authorized to run — they click, Django 403s. That's not a hole (the target self-gates, the URL isn't secret), but it's inconsistent with the posture elsewhere in this codebase, which deliberately never advertises a link the user can't use:

Since there's no clean per-view permission signal from get_urls(), I'd accept it for Option A (a transition bridge) but document it explicitly as a known limitation in the §2/§4 custom_views contract addition ("these are advertised regardless of the custom view's own permission; clicking an unauthorized one yields Django's 403"), so it's a conscious choice, not a silent gap. A future iframe/native approach (Option C) could surface the permission if the consumer opts in.

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.

Copy link
Copy Markdown
Contributor

@martin-castro-laminr-ai martin-castro-laminr-ai left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@MartinCastroAlvarez
Copy link
Copy Markdown
Owner Author

Reopening — user lifted the Tier 5/6 human-gate this session. Will re-run CI under the new test-gate (#506/#452) and merge if green.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants