diff --git a/django_admin_react/api/README.md b/django_admin_react/api/README.md
index 0159ba6..41bc15e 100644
--- a/django_admin_react/api/README.md
+++ b/django_admin_react/api/README.md
@@ -26,6 +26,7 @@ the design.
| `permissions.py` | Staff + AdminSite.has_permission gate; per-op delegation. |
| `registry.py` | AdminSite introspection helpers. |
| `serializers.py` | Conservative field serialization + denylist. |
+| `custom_views.py` | Surface a ModelAdmin's custom `get_urls()` routes (#439). |
| `views/` | One module per endpoint. |
Implementation status is tracked in `../README.md`.
diff --git a/django_admin_react/api/custom_views.py b/django_admin_react/api/custom_views.py
new file mode 100644
index 0000000..efa7bb0
--- /dev/null
+++ b/django_admin_react/api/custom_views.py
@@ -0,0 +1,216 @@
+"""Surface a ``ModelAdmin``'s *custom* admin views to the SPA (Issue #439).
+
+Many consumers add bespoke admin pages — report / import-export /
+dashboard pages and per-object tool views — by overriding
+``ModelAdmin.get_urls()`` (or ``AdminSite.get_urls()``). The SPA cannot
+render those Django-templated pages itself, but it can **link out** to
+them (Option A): a real ```` to the legacy
+admin-rendered page. This module is the design-safe foundation — the
+same ``{name, label, url, level}`` payload also powers a future iframe
+or native approach.
+
+How custom routes are distinguished from the standard CRUD set
+----------------------------------------------------------------
+
+Django's ``ModelAdmin.get_urls`` always emits five *named* routes for a
+model — ``__{changelist,add,change,delete,history}`` — plus
+one unnamed catch-all (the legacy ``/`` redirect, ``name=None``).
+Anything a consumer adds on top has a different (or no) standard suffix.
+We compute the standard names from ``model._meta`` and treat every
+remaining *named* route as a custom view. Unnamed routes are skipped:
+without a name they can't be ``reverse()``-d through the admin
+namespace, so the SPA could never link to them anyway.
+
+How URLs are reversed
+---------------------
+
+Custom admin routes live under the **admin site's** URL namespace
+(``admin_site.name``, e.g. ``"admin"``). We reverse each route as
+``reverse(f"{admin_site.name}:{name}", args=[...])`` — ``args=[obj.pk]``
+for an object-level route, ``args=[]`` for a changelist-level one. Every
+reverse is guarded; an un-reversible route (legacy admin not mounted,
+extra capture groups we don't fill, a consumer ``reverse`` quirk) is
+silently skipped rather than raising.
+
+Hardening (``SECURITY.md`` §3): this module introduces **no new
+permission surface**. It is only ever called from the detail / registry
+views, *after* their staff + ``has_view_permission`` gates have run, and
+it never reads object data beyond the ``pk`` it is handed. A misbehaving
+consumer ``get_urls`` must never 500 a response — every introspection
+step degrades to ``[]``.
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+from django.contrib.admin.options import ModelAdmin
+from django.db.models import Model
+from django.urls import reverse
+from django.utils.text import capfirst
+
+# Capture-group names Django uses for the per-object segment of a
+# ``ModelAdmin`` route. If a custom route's regex captures any of these
+# (or, defensively, a generic ``pk``), it is an *object-level* view that
+# needs an object id to reverse.
+_OBJECT_ID_GROUPS: frozenset[str] = frozenset({"object_id", "pk"})
+
+
+def _standard_route_names(model: type[Model]) -> frozenset[str]:
+ """The five route names Django's ``ModelAdmin.get_urls`` always emits.
+
+ Built from ``model._meta`` exactly the way Django builds them in
+ ``ModelAdmin.get_urls`` (``info = app_label, model_name``), so this
+ stays correct for any model without depending on Django internals.
+ """
+ info = f"{model._meta.app_label}_{model._meta.model_name}"
+ return frozenset(
+ {
+ f"{info}_changelist",
+ f"{info}_add",
+ f"{info}_change",
+ f"{info}_delete",
+ f"{info}_history",
+ }
+ )
+
+
+def _is_object_level(pattern: Any) -> bool:
+ """``True`` if the URL pattern captures an object id.
+
+ Inspects the compiled regex's named capture groups for an
+ ``object_id`` / ``pk`` group — the marker that the route is a
+ per-object tool view (e.g. ``/make-report/``) rather than a
+ page that stands alone (e.g. ``import/``). Any introspection error
+ is treated as "not object-level" (changelist-level) so we still
+ attempt a no-args reverse.
+ """
+ try:
+ groups = pattern.regex.groupindex.keys()
+ except Exception:
+ return False
+ return any(group in _OBJECT_ID_GROUPS for group in groups)
+
+
+def _label_for_route(entry: Any, name: str) -> str:
+ """Human-readable label for a custom route.
+
+ Prefers a ``short_description`` on the view callable (Django's own
+ convention for naming admin callables); falls back to humanising the
+ route name — ``capfirst(name.replace("_", " "))``. The model/app
+ prefix that ``get_urls`` bakes into the name (``__``) is
+ stripped first so the label reads as the *action* (e.g.
+ ``"Send report"``) rather than the wiring.
+ """
+ callback = getattr(entry, "callback", None)
+ short = getattr(callback, "short_description", None)
+ if short:
+ return str(short)
+ return str(capfirst(name.replace("_", " ")))
+
+
+def _reverse_or_none(admin_site: Any, name: str, args: list[Any]) -> str | None:
+ """Reverse ``:`` with ``args``, or ``None``.
+
+ Guards every reverse: an un-reversible route (legacy admin not
+ mounted, a route needing more args than we supply, a custom
+ namespace quirk) degrades to ``None`` and is dropped by the caller.
+ """
+ site_name = getattr(admin_site, "name", None)
+ if not site_name:
+ return None
+ try:
+ return reverse(f"{site_name}:{name}", args=args)
+ except Exception:
+ return None
+
+
+def custom_views_for(
+ model_admin: ModelAdmin,
+ admin_site: Any,
+ *,
+ obj: Model | None = None,
+) -> list[dict[str, Any]]:
+ """Custom (non-CRUD) admin views for a ``ModelAdmin`` (Issue #439).
+
+ Walks ``model_admin.get_urls()``, drops the five standard CRUD
+ routes (and any unnamed route), and returns a descriptor per
+ remaining custom route::
+
+ {"name": str, "label": str, "url": str, "level": "object"|"changelist"}
+
+ Reversal rules:
+
+ - ``level == "object"`` routes need an object id. When ``obj`` is
+ provided they are reversed with ``args=[obj.pk]``; when ``obj`` is
+ ``None`` (the registry / changelist context) they are skipped —
+ there is no object to point them at.
+ - ``level == "changelist"`` routes are reversed with ``args=[]``.
+
+ Returns ``[]`` when the admin exposes no custom routes, or when none
+ of them reverse. NEVER raises: a misbehaving consumer ``get_urls``
+ degrades to ``[]`` (so the caller's detail / registry response is
+ unaffected).
+ """
+ try:
+ urls = model_admin.get_urls()
+ except Exception:
+ return []
+
+ try:
+ standard = _standard_route_names(model_admin.model)
+ except Exception:
+ return []
+
+ out: list[dict[str, Any]] = []
+ for entry in urls:
+ descriptor = _descriptor_for_route(entry, standard, admin_site, obj)
+ if descriptor is not None:
+ out.append(descriptor)
+ return out
+
+
+def _descriptor_for_route(
+ entry: Any,
+ standard: frozenset[str],
+ admin_site: Any,
+ obj: Model | None,
+) -> dict[str, Any] | None:
+ """Build one custom-view descriptor, or ``None`` to skip the route.
+
+ A route is skipped (``None``) when it is unnamed, is a standard CRUD
+ route, is object-level but we have no object, fails to reverse, or
+ raises during introspection. One bad route never sinks the rest:
+ every failure degrades to ``None`` rather than propagating.
+ """
+ try:
+ name = getattr(entry, "name", None)
+ # Unnamed routes (Django's legacy ``/`` catch-all) and the
+ # five standard CRUD routes are not "custom views".
+ if not name or name in standard:
+ return None
+
+ pattern = getattr(entry, "pattern", None)
+ object_level = _is_object_level(pattern) if pattern is not None else False
+
+ if object_level:
+ if obj is None:
+ # No object to anchor the route to in this context.
+ return None
+ url = _reverse_or_none(admin_site, name, [obj.pk])
+ level = "object"
+ else:
+ url = _reverse_or_none(admin_site, name, [])
+ level = "changelist"
+
+ if url is None:
+ return None
+
+ return {
+ "name": str(name),
+ "label": _label_for_route(entry, str(name)),
+ "url": url,
+ "level": level,
+ }
+ except Exception:
+ return None
diff --git a/django_admin_react/api/registry.py b/django_admin_react/api/registry.py
index b58a499..488eee3 100644
--- a/django_admin_react/api/registry.py
+++ b/django_admin_react/api/registry.py
@@ -17,6 +17,8 @@
from django.http import HttpRequest
from django.utils.module_loading import import_string
+from django_admin_react.api.custom_views import custom_views_for
+
def get_admin_site() -> AdminSite:
"""Resolve the configured admin site instance.
@@ -70,16 +72,27 @@ def _model_permissions(model_admin: ModelAdmin, request: HttpRequest) -> dict[st
}
-def _model_entry(model: type[Model], model_admin: ModelAdmin, request: HttpRequest) -> dict:
+def _model_entry(
+ model: type[Model],
+ model_admin: ModelAdmin,
+ request: HttpRequest,
+ admin_site: AdminSite,
+) -> dict:
"""Single ``models[]`` element for the registry response.
Wire shape is documented in ``docs/api-contract.md`` §2. Only
metadata + the four ``has_*_permission`` booleans go on the wire;
no model field schemas, no row counts — those are detail/list
endpoint responsibilities.
+
+ Changelist-level custom views (Issue #439) are attached when the
+ consumer's ``ModelAdmin.get_urls`` exposes any — so the SPA can link
+ to a model-wide report / import page from the list/home. Object-level
+ custom views are *not* surfaced here (no object to anchor them to);
+ those live on the detail payload. The key is omitted when empty.
"""
meta = model._meta
- return {
+ entry = {
"app_label": meta.app_label,
"model_name": meta.model_name,
"object_name": meta.object_name,
@@ -87,6 +100,10 @@ def _model_entry(model: type[Model], model_admin: ModelAdmin, request: HttpReque
"verbose_name_plural": str(meta.verbose_name_plural),
"permissions": _model_permissions(model_admin, request),
}
+ extra_views = custom_views_for(model_admin, admin_site, obj=None)
+ if extra_views:
+ entry["custom_views"] = extra_views
+ return entry
def _user_payload(request: HttpRequest) -> dict:
@@ -195,7 +212,7 @@ def build_registry_payload(admin_site: AdminSite, request: HttpRequest) -> dict:
# ``SECURITY.md`` §3).
if not model_admin.has_view_permission(request):
continue
- entry = _model_entry(model, model_admin, request)
+ entry = _model_entry(model, model_admin, request, admin_site)
entry["real_app_label"] = model._meta.app_label
entry["app_label"] = group_label
models_payload.append(entry)
diff --git a/django_admin_react/api/views/detail.py b/django_admin_react/api/views/detail.py
index d0ce51d..5a547e9 100644
--- a/django_admin_react/api/views/detail.py
+++ b/django_admin_react/api/views/detail.py
@@ -31,6 +31,7 @@
from django.http import JsonResponse
from django.views.generic import View
+from django_admin_react.api.custom_views import custom_views_for
from django_admin_react.api.inlines import inlines_payload
from django_admin_react.api.permissions import forbidden_response
from django_admin_react.api.permissions import is_admin_user
@@ -119,7 +120,7 @@ def _build_payload(
) -> dict[str, Any]:
"""Compose the full detail response body (contract §4)."""
visible_names = _visible_field_names(model_admin, request, obj)
- return {
+ payload: dict[str, Any] = {
"app_label": model._meta.app_label,
"model_name": model._meta.model_name,
"pk": obj.pk,
@@ -132,6 +133,16 @@ def _build_payload(
"inlines": inlines_payload(model_admin, obj, request, admin_site),
"view_on_site_url": _view_on_site_url(model_admin, obj),
}
+ # Custom admin views (Issue #439): link-outs to the consumer's bespoke
+ # admin pages reached via ``ModelAdmin.get_urls()``. Object-level routes
+ # are reversed with this object's pk; changelist-level routes (simple,
+ # no-arg) are included too so the SPA can offer them from the detail
+ # toolbar. Only attached when non-empty so older clients and plain
+ # admins see no extra key.
+ extra_views = custom_views_for(model_admin, admin_site, obj=obj)
+ if extra_views:
+ payload["custom_views"] = extra_views
+ return payload
def _view_on_site_url(model_admin: ModelAdmin, obj: Model) -> str | None:
diff --git a/frontend/apps/web/src/pages/DetailPage.tsx b/frontend/apps/web/src/pages/DetailPage.tsx
index ada383c..ad44f86 100644
--- a/frontend/apps/web/src/pages/DetailPage.tsx
+++ b/frontend/apps/web/src/pages/DetailPage.tsx
@@ -21,6 +21,7 @@ import {
updateObject,
useApiClient,
useDetail,
+ type CustomView,
type DeletePreviewResponse,
type DetailResponse,
type FieldDescriptor,
@@ -229,6 +230,9 @@ export function DetailPage() {
View on site
)}
+ {data.custom_views && data.custom_views.length > 0 && (
+
+ )}
{canChange && (