Skip to content

Commit 02470b8

Browse files
feat(api): object-level change-page actions (django-object-actions)
Surface and run a ModelAdmin's object-level change-page actions in the SPA — the django-object-actions `change_actions` / `get_change_actions` affordance. Support is fully optional and duck-typed: no new dependency, and a plain-Django admin (no such hook/attr) emits nothing, so the detail payload omits `object_actions` entirely (a no-op). Backend: - New `api/object_actions.py` resolves the *permitted* action set via `get_change_actions(request, {}, str(pk))` (which filters by each action's declared permission, like django-object-actions) or falls back to the `change_actions` attribute. Builds the detail payload's `object_actions: [{name, label, description}]` block. - Detail view emits `object_actions` only when the admin opts in. - New `POST /api/v1/<app>/<model>/<pk>/action/<name>/` runner: staff + resolve_model + load via get_queryset + per-object change-permission gate + the name MUST be in the permitted set (else 404 — never trusts the URL name). Calls `method(request, obj)`; a redirect response is surfaced as `redirect` (the API returns 200, not a 302). A raising callable is caught → `{ok:false, error}` 400, never a 500. CSRF enforced (not exempt). Frontend: - contract: `object_actions?` on DetailResponse + `ObjectActionRunResponse`. - @dar/api `runObjectAction` (POST, CSRF) threaded through @dar/data. - DetailPage renders a button per action next to Edit/Delete; on success re-fetches the detail payload (computed/readonly fields may change) and toasts, or navigates on redirect. No full-page reload. Tests: tests/test_object_actions.py covers the mandatory matrix (anon, non-staff, no change-perm, staff-with-perm runs, unknown name 404, not-permitted 404, unregistered model 404, missing CSRF 403) plus the detail-payload block (omitted for plain admin, label/description resolution, permission filtering) and redirect / raising-action paths. Closes #236
1 parent 743473e commit 02470b8

10 files changed

Lines changed: 912 additions & 4 deletions

File tree

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
"""Object-level change-page actions (Issue #236).
2+
3+
Django's *built-in* admin has no per-object change-page action buttons;
4+
the popular `django-object-actions
5+
<https://github.com/crccheck/django-object-actions>`_ library adds them
6+
via ``ModelAdmin.change_actions`` / ``get_change_actions(request, context,
7+
object_id)``. The library stores callables resolved *by name* and binds
8+
each as a method on the admin instance, so ``getattr(model_admin, name)``
9+
returns the bound callable; it is invoked as ``method(request, obj)`` and
10+
may return ``None`` or an ``HttpResponse``.
11+
12+
This module duck-types that contract so the SPA can surface and run those
13+
actions **when they exist** — without taking a hard dependency on
14+
``django-object-actions``. A plain-Django admin (no
15+
``get_change_actions`` / ``change_actions``) yields the empty list, so the
16+
detail payload simply omits the ``object_actions`` key (a graceful no-op).
17+
18+
Security posture (`SECURITY.md` §3):
19+
20+
- The set of *permitted* actions is resolved through the admin's own
21+
``get_change_actions(request, {}, str(obj.pk))`` when available — which
22+
is exactly how django-object-actions filters by the action's declared
23+
permission (``_get_permissions_for_action``). The action-run view never
24+
trusts the URL ``name`` until it appears in that permitted set.
25+
- We never import or assume ``django-object-actions``; everything is
26+
attribute duck-typing guarded so a misbehaving admin can't 500 the
27+
endpoint.
28+
"""
29+
30+
from __future__ import annotations
31+
32+
from typing import Any
33+
34+
from django.contrib.admin.options import ModelAdmin
35+
from django.http import HttpRequest
36+
from django.utils.text import capfirst
37+
38+
39+
def permitted_action_names(
40+
model_admin: ModelAdmin,
41+
request: HttpRequest,
42+
obj: Any,
43+
) -> list[str] | None:
44+
"""Return the object-action names this request may run, or ``None``.
45+
46+
Resolution order mirrors django-object-actions:
47+
48+
1. ``get_change_actions(request, context, object_id)`` — the
49+
permission-aware hook. When present we call it with an empty
50+
context and the object's pk; the library returns only the action
51+
names the user is allowed to run (it applies each action's declared
52+
permission internally). This is the authoritative, permission-gated
53+
list.
54+
2. ``change_actions`` — a plain attribute (list/tuple of names) when
55+
the admin defines the actions but not the filtering hook. Without
56+
a permission hook we surface the declared names as-is; the
57+
action-run view still gates the *object* by ``has_change_permission``
58+
(object actions are change-shaped).
59+
60+
Returns ``None`` when the admin exposes neither — the caller then
61+
omits ``object_actions`` entirely (no-op for a plain-Django admin).
62+
A list (possibly empty) means the admin opts in.
63+
"""
64+
hook = getattr(model_admin, "get_change_actions", None)
65+
if callable(hook):
66+
try:
67+
names = hook(request, {}, str(obj.pk) if obj is not None else None)
68+
except Exception:
69+
# A consumer hook that raises must not 500 the detail view —
70+
# degrade to "no actions" rather than leaking the failure.
71+
return []
72+
return [str(n) for n in (names or ())]
73+
74+
declared = getattr(model_admin, "change_actions", None)
75+
if declared is not None:
76+
return [str(n) for n in (declared or ())]
77+
78+
return None
79+
80+
81+
def resolve_action_callable(model_admin: ModelAdmin, name: str) -> Any | None:
82+
"""Resolve one object-action name to its bound callable, or ``None``.
83+
84+
django-object-actions binds each action as a method on the admin
85+
instance (whether it was declared as a method name on the admin or as
86+
a key in ``change_actions``), so ``getattr(model_admin, name)`` is the
87+
single resolution path. Never trust ``name`` as an arbitrary attribute
88+
lookup: the caller must first confirm ``name`` is in
89+
:func:`permitted_action_names`, so we only reach a vetted attribute
90+
here.
91+
"""
92+
candidate = getattr(model_admin, name, None)
93+
return candidate if callable(candidate) else None
94+
95+
96+
def object_actions_payload(
97+
model_admin: ModelAdmin,
98+
request: HttpRequest,
99+
obj: Any,
100+
) -> list[dict[str, str]] | None:
101+
"""Build the detail payload's ``object_actions`` block, or ``None``.
102+
103+
Each entry is ``{name, label, description}``:
104+
105+
- ``label`` — the action method's ``label`` attribute if set, else a
106+
humanized form of the name (``do_thing`` → ``Do thing``), matching
107+
django-object-actions' own ``capfirst(name.replace("_", " "))``.
108+
- ``description`` — the method's ``short_description`` if set, else the
109+
first line of its ``__doc__``; omitted when neither is present.
110+
111+
Only *permitted* actions (see :func:`permitted_action_names`) are
112+
included. Returns ``None`` when the admin doesn't expose object
113+
actions at all, so the caller can drop the key from the response.
114+
"""
115+
names = permitted_action_names(model_admin, request, obj)
116+
if names is None:
117+
return None
118+
119+
out: list[dict[str, str]] = []
120+
for name in names:
121+
callable_ = resolve_action_callable(model_admin, name)
122+
if callable_ is None:
123+
# Declared but unresolvable (e.g. a stale name) — skip rather
124+
# than surfacing a button that the run endpoint would reject.
125+
continue
126+
entry: dict[str, str] = {
127+
"name": name,
128+
"label": _action_label(callable_, name),
129+
}
130+
description = _action_description(callable_)
131+
if description:
132+
entry["description"] = description
133+
out.append(entry)
134+
return out
135+
136+
137+
def _action_label(action_callable: Any, name: str) -> str:
138+
"""Human-readable button label for an object action."""
139+
label = getattr(action_callable, "label", None)
140+
if label:
141+
return str(label)
142+
return str(capfirst(name.replace("_", " ")))
143+
144+
145+
def _action_description(action_callable: Any) -> str:
146+
"""Help text for an object action: ``short_description`` or docstring."""
147+
short = getattr(action_callable, "short_description", None)
148+
if short:
149+
return str(short)
150+
doc = getattr(action_callable, "__doc__", None)
151+
if doc:
152+
first_line = doc.strip().splitlines()[0].strip() if doc.strip() else ""
153+
if first_line:
154+
return first_line
155+
return ""

django_admin_react/api/urls.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
from django_admin_react.api.views.detail import DetailView
3333
from django_admin_react.api.views.history import HistoryView
3434
from django_admin_react.api.views.list import ListView
35+
from django_admin_react.api.views.object_action import ObjectActionView
3536
from django_admin_react.api.views.password import SetPasswordView
3637
from django_admin_react.api.views.recent_actions import RecentActionsView
3738
from django_admin_react.api.views.registry import RegistryView
@@ -162,6 +163,17 @@ def delete(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpRespons
162163
SetPasswordView.as_view(),
163164
name="set_password",
164165
),
166+
# Object-level change-page action runner (#236) — opt-in via the
167+
# django-object-actions ``change_actions`` / ``get_change_actions``
168+
# contract, duck-typed (no hard dependency). Literal ``action``
169+
# segment must precede the ``<pk>`` instance route below so it isn't
170+
# swallowed as part of the pk. 404s for any model whose admin exposes
171+
# no object actions.
172+
path(
173+
"<str:app_label>/<str:model_name>/<str:pk>/action/<str:name>/",
174+
ObjectActionView.as_view(),
175+
name="object_action",
176+
),
165177
path(
166178
"<str:app_label>/<str:model_name>/<str:pk>/",
167179
InstanceView.as_view(),

django_admin_react/api/views/detail.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
from django.views.generic import View
3636

3737
from django_admin_react.api.inlines import inlines_payload
38+
from django_admin_react.api.object_actions import object_actions_payload
3839
from django_admin_react.api.permissions import forbidden_response
3940
from django_admin_react.api.permissions import is_admin_user
4041
from django_admin_react.api.registry import get_admin_site
@@ -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,15 @@ 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+
# Object-level change-page actions (Issue #236) — opt-in via the
145+
# django-object-actions ``get_change_actions`` / ``change_actions``
146+
# contract, duck-typed (no hard dependency). ``None`` means a plain
147+
# Django admin without that affordance: omit the key entirely so the
148+
# SPA renders no action buttons (graceful no-op).
149+
object_actions = object_actions_payload(model_admin, request, obj)
150+
if object_actions is not None:
151+
payload["object_actions"] = object_actions
152+
return payload
143153

144154

145155
def _view_on_site_url(model_admin: ModelAdmin, obj: Model) -> str | None:
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
"""``POST /api/v1/<app>/<model>/<pk>/action/<name>/`` — run an object action.
2+
3+
Wire contract: ``docs/api-contract.md`` §5 (an addition the human reviewer
4+
must land — see the PR/report note).
5+
6+
Object-level change-page actions are the django-object-actions affordance
7+
(``ModelAdmin.change_actions`` / ``get_change_actions``). This endpoint
8+
runs one named action against a single object. It is the per-object
9+
counterpart to the changelist ``actions`` runner (``views/actions.py``);
10+
both never trust the client-supplied name as a callable lookup.
11+
12+
Hard rules (`SECURITY.md` §3, `ACCEPTANCE.md` §3.1):
13+
14+
- Rule 1: Staff + ``AdminSite.has_permission`` gate.
15+
- Rule 3: Model resolved through ``admin.site._registry`` (B-7).
16+
- Rule 5: ``has_change_permission(request, obj)`` per-object gate — object
17+
actions are change-shaped, matching how django-object-actions
18+
wires them onto the change view.
19+
- Rule 10: Object loaded through ``ModelAdmin.get_object`` /
20+
``get_queryset`` — never ``Model.objects.all()`` (B-2).
21+
- Rule 12: The action ``name`` is verified against the *permitted* set
22+
(``get_change_actions``) before the callable runs; an unknown /
23+
non-permitted name is a 404 and the callable never executes.
24+
Action callables may raise — we catch and return a clean 400
25+
``{ok: false, error}`` rather than a 500 (per the issue brief).
26+
- CSRF: No ``@csrf_exempt`` — Django's middleware enforces.
27+
"""
28+
29+
from __future__ import annotations
30+
31+
from typing import Any
32+
33+
from django.http import HttpRequest
34+
from django.http import HttpResponse
35+
from django.http import JsonResponse
36+
from django.views.generic import View
37+
38+
from django_admin_react.api.object_actions import permitted_action_names
39+
from django_admin_react.api.object_actions import resolve_action_callable
40+
from django_admin_react.api.permissions import forbidden_response
41+
from django_admin_react.api.permissions import is_admin_user
42+
from django_admin_react.api.registry import get_admin_site
43+
from django_admin_react.api.registry import resolve_model
44+
from django_admin_react.api.writes import load_object_or_none
45+
from django_admin_react.api.writes import not_found_response
46+
47+
48+
class ObjectActionView(View):
49+
"""``POST /api/v1/<app_label>/<model_name>/<pk>/action/<name>/``."""
50+
51+
http_method_names = ["post"]
52+
53+
def post(
54+
self,
55+
request: HttpRequest,
56+
app_label: str,
57+
model_name: str,
58+
pk: str,
59+
name: str,
60+
*args: Any,
61+
**kwargs: Any,
62+
) -> HttpResponse:
63+
"""Run one permitted object action against a single object.
64+
65+
Gates, in order:
66+
67+
1. ``is_admin_user`` — 403 if not authenticated active staff.
68+
2. ``resolve_model`` — 404 if the model is unknown / unviewable.
69+
3. ``load_object_or_none`` — 404 if the pk doesn't resolve under
70+
the admin's queryset (rule 10) or parse-fails.
71+
4. ``has_change_permission(request, obj)`` — per-object gate
72+
(object actions are change-shaped).
73+
5. ``permitted_action_names`` — 404 unless ``name`` is in the set
74+
the admin permits for this user + object (the callable never
75+
runs for a name not in that set).
76+
77+
On success the response is ``{ok: true, message?, redirect?}``: if
78+
the callable returns a redirect ``HttpResponse`` we surface its URL
79+
as ``redirect`` (the API itself returns 200, never a 302, so the
80+
SPA controls navigation). If the callable raises, the response is
81+
``{ok: false, error}`` with status 400 — never a 500.
82+
"""
83+
admin_site = get_admin_site()
84+
if not is_admin_user(request, admin_site=admin_site):
85+
return forbidden_response(request)
86+
87+
resolved = resolve_model(admin_site, request, app_label, model_name)
88+
if resolved is None:
89+
return not_found_response()
90+
model, model_admin = resolved
91+
92+
obj = load_object_or_none(model, model_admin, request, pk)
93+
if obj is None:
94+
return not_found_response()
95+
96+
# Object actions are change-shaped — gate on change permission for
97+
# this object, the same posture django-object-actions takes by
98+
# wiring the buttons onto the change view.
99+
if not model_admin.has_change_permission(request, obj):
100+
return forbidden_response(request)
101+
102+
# Never trust the URL ``name``: it must be in the admin's own
103+
# permitted set. ``None`` means the admin exposes no object actions
104+
# at all → 404 (the endpoint doesn't exist for this model).
105+
permitted = permitted_action_names(model_admin, request, obj)
106+
if permitted is None or name not in permitted:
107+
return not_found_response()
108+
109+
action_callable = resolve_action_callable(model_admin, name)
110+
if action_callable is None:
111+
return not_found_response()
112+
113+
# The action manages its own writes (it may or may not mutate), so
114+
# we do NOT force a surrounding ``transaction.atomic()`` — matching
115+
# django-object-actions, which calls the bound method directly. A
116+
# raising callable is caught and returned as a clean 400, never a
117+
# 500 (per the issue brief).
118+
try:
119+
result = action_callable(request, obj)
120+
except Exception:
121+
# The action callable raised — return a clean 400, never a 500.
122+
# The exception text can disclose internal detail, so we surface
123+
# only a generic, safe message (``SECURITY.md`` §3 rule 12); the
124+
# real cause is in the consumer's server logs, not the wire.
125+
return _json_response(
126+
{"ok": False, "error": "The action could not be completed."},
127+
status=400,
128+
)
129+
130+
return _success_response(result)
131+
132+
133+
def _success_response(result: Any) -> HttpResponse:
134+
"""Build the ``{ok: true, message?, redirect?}`` success envelope.
135+
136+
A redirect ``HttpResponse`` from the callable is surfaced as a
137+
``redirect`` URL (we never echo a 302 — the SPA navigates client-side
138+
without a full-page reload). Any other ``HttpResponse`` is treated as
139+
"ran successfully" with no extra payload. The django-object-actions
140+
contract is that an action returns ``None`` (just ran) or an
141+
``HttpResponse`` (commonly a redirect).
142+
"""
143+
body: dict[str, Any] = {"ok": True}
144+
if isinstance(result, HttpResponse):
145+
# A redirect response carries a ``Location`` header (3xx). Mirror
146+
# the changelist action runner (``views/actions.py``): surface the
147+
# target so the SPA navigates client-side. ``url`` is set on
148+
# ``HttpResponseRedirect`` subclasses; the header is the fallback.
149+
target = getattr(result, "url", None) or result.get("Location", None)
150+
if target:
151+
body["redirect"] = str(target)
152+
return _json_response(body, status=200)
153+
154+
155+
def _json_response(body: dict[str, Any], status: int) -> HttpResponse:
156+
"""JSON envelope with the package's standard no-store cache header."""
157+
response = JsonResponse(body, status=status)
158+
response["Cache-Control"] = "no-store"
159+
return response

0 commit comments

Comments
 (0)