Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions django_admin_react/api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
216 changes: 216 additions & 0 deletions django_admin_react/api/custom_views.py
Original file line number Diff line number Diff line change
@@ -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 ``<a target="_blank">`` 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 — ``<app>_<model>_{changelist,add,change,delete,history}`` — plus
one unnamed catch-all (the legacy ``<pk>/`` 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. ``<pk>/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 (``<app>_<model>_``) 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 ``<admin_site.name>:<name>`` 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 ``<pk>/`` 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
23 changes: 20 additions & 3 deletions django_admin_react/api/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -70,23 +72,38 @@ 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,
"verbose_name": str(meta.verbose_name),
"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:
Expand Down Expand Up @@ -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)
Expand Down
13 changes: 12 additions & 1 deletion django_admin_react/api/views/detail.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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:
Expand Down
Loading
Loading