Skip to content
Merged
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
155 changes: 155 additions & 0 deletions django_admin_react/api/object_actions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
"""Object-level change-page actions (Issue #236).

Django's *built-in* admin has no per-object change-page action buttons;
the popular `django-object-actions
<https://github.com/crccheck/django-object-actions>`_ library adds them
via ``ModelAdmin.change_actions`` / ``get_change_actions(request, context,
object_id)``. The library stores callables resolved *by name* and binds
each as a method on the admin instance, so ``getattr(model_admin, name)``
returns the bound callable; it is invoked as ``method(request, obj)`` and
may return ``None`` or an ``HttpResponse``.

This module duck-types that contract so the SPA can surface and run those
actions **when they exist** — without taking a hard dependency on
``django-object-actions``. A plain-Django admin (no
``get_change_actions`` / ``change_actions``) yields the empty list, so the
detail payload simply omits the ``object_actions`` key (a graceful no-op).

Security posture (`SECURITY.md` §3):

- The set of *permitted* actions is resolved through the admin's own
``get_change_actions(request, {}, str(obj.pk))`` when available — which
is exactly how django-object-actions filters by the action's declared
permission (``_get_permissions_for_action``). The action-run view never
trusts the URL ``name`` until it appears in that permitted set.
- We never import or assume ``django-object-actions``; everything is
attribute duck-typing guarded so a misbehaving admin can't 500 the
endpoint.
"""

from __future__ import annotations

from typing import Any

from django.contrib.admin.options import ModelAdmin
from django.http import HttpRequest
from django.utils.text import capfirst


def permitted_action_names(
model_admin: ModelAdmin,
request: HttpRequest,
obj: Any,
) -> list[str] | None:
"""Return the object-action names this request may run, or ``None``.

Resolution order mirrors django-object-actions:

1. ``get_change_actions(request, context, object_id)`` — the
permission-aware hook. When present we call it with an empty
context and the object's pk; the library returns only the action
names the user is allowed to run (it applies each action's declared
permission internally). This is the authoritative, permission-gated
list.
2. ``change_actions`` — a plain attribute (list/tuple of names) when
the admin defines the actions but not the filtering hook. Without
a permission hook we surface the declared names as-is; the
action-run view still gates the *object* by ``has_change_permission``
(object actions are change-shaped).

Returns ``None`` when the admin exposes neither — the caller then
omits ``object_actions`` entirely (no-op for a plain-Django admin).
A list (possibly empty) means the admin opts in.
"""
hook = getattr(model_admin, "get_change_actions", None)
if callable(hook):
try:
names = hook(request, {}, str(obj.pk) if obj is not None else None)
except Exception:
# A consumer hook that raises must not 500 the detail view —
# degrade to "no actions" rather than leaking the failure.
return []
return [str(n) for n in (names or ())]

declared = getattr(model_admin, "change_actions", None)
if declared is not None:
return [str(n) for n in (declared or ())]

return None


def resolve_action_callable(model_admin: ModelAdmin, name: str) -> Any | None:
"""Resolve one object-action name to its bound callable, or ``None``.

django-object-actions binds each action as a method on the admin
instance (whether it was declared as a method name on the admin or as
a key in ``change_actions``), so ``getattr(model_admin, name)`` is the
single resolution path. Never trust ``name`` as an arbitrary attribute
lookup: the caller must first confirm ``name`` is in
:func:`permitted_action_names`, so we only reach a vetted attribute
here.
"""
candidate = getattr(model_admin, name, None)
return candidate if callable(candidate) else None


def object_actions_payload(
model_admin: ModelAdmin,
request: HttpRequest,
obj: Any,
) -> list[dict[str, str]] | None:
"""Build the detail payload's ``object_actions`` block, or ``None``.

Each entry is ``{name, label, description}``:

- ``label`` — the action method's ``label`` attribute if set, else a
humanized form of the name (``do_thing`` → ``Do thing``), matching
django-object-actions' own ``capfirst(name.replace("_", " "))``.
- ``description`` — the method's ``short_description`` if set, else the
first line of its ``__doc__``; omitted when neither is present.

Only *permitted* actions (see :func:`permitted_action_names`) are
included. Returns ``None`` when the admin doesn't expose object
actions at all, so the caller can drop the key from the response.
"""
names = permitted_action_names(model_admin, request, obj)
if names is None:
return None

out: list[dict[str, str]] = []
for name in names:
callable_ = resolve_action_callable(model_admin, name)
if callable_ is None:
# Declared but unresolvable (e.g. a stale name) — skip rather
# than surfacing a button that the run endpoint would reject.
continue
entry: dict[str, str] = {
"name": name,
"label": _action_label(callable_, name),
}
description = _action_description(callable_)
if description:
entry["description"] = description
out.append(entry)
return out


def _action_label(action_callable: Any, name: str) -> str:
"""Human-readable button label for an object action."""
label = getattr(action_callable, "label", None)
if label:
return str(label)
return str(capfirst(name.replace("_", " ")))


def _action_description(action_callable: Any) -> str:
"""Help text for an object action: ``short_description`` or docstring."""
short = getattr(action_callable, "short_description", None)
if short:
return str(short)
doc = getattr(action_callable, "__doc__", None)
if doc:
first_line = doc.strip().splitlines()[0].strip() if doc.strip() else ""
if first_line:
return first_line
return ""
12 changes: 12 additions & 0 deletions django_admin_react/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
from django_admin_react.api.views.detail import DetailView
from django_admin_react.api.views.history import HistoryView
from django_admin_react.api.views.list import ListView
from django_admin_react.api.views.object_action import ObjectActionView
from django_admin_react.api.views.password import SetPasswordView
from django_admin_react.api.views.recent_actions import RecentActionsView
from django_admin_react.api.views.registry import RegistryView
Expand Down Expand Up @@ -162,6 +163,17 @@ def delete(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpRespons
SetPasswordView.as_view(),
name="set_password",
),
# Object-level change-page action runner (#236) — opt-in via the
# django-object-actions ``change_actions`` / ``get_change_actions``
# contract, duck-typed (no hard dependency). Literal ``action``
# segment must precede the ``<pk>`` instance route below so it isn't
# swallowed as part of the pk. 404s for any model whose admin exposes
# no object actions.
path(
"<str:app_label>/<str:model_name>/<str:pk>/action/<str:name>/",
ObjectActionView.as_view(),
name="object_action",
),
path(
"<str:app_label>/<str:model_name>/<str:pk>/",
InstanceView.as_view(),
Expand Down
9 changes: 9 additions & 0 deletions django_admin_react/api/views/detail.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@

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.object_actions import object_actions_payload
from django_admin_react.api.permissions import forbidden_response
from django_admin_react.api.permissions import is_admin_user
from django_admin_react.api.registry import get_admin_site
Expand Down Expand Up @@ -150,6 +151,14 @@ def _build_payload(
extra_views = custom_views_for(model_admin, admin_site, obj=obj)
if extra_views:
payload["custom_views"] = extra_views
# Object-level change-page actions (Issue #236) — opt-in via the
# django-object-actions ``get_change_actions`` / ``change_actions``
# contract, duck-typed (no hard dependency). ``None`` means a plain
# Django admin without that affordance: omit the key entirely so the
# SPA renders no action buttons (graceful no-op).
object_actions = object_actions_payload(model_admin, request, obj)
if object_actions is not None:
payload["object_actions"] = object_actions
return payload


Expand Down
159 changes: 159 additions & 0 deletions django_admin_react/api/views/object_action.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
"""``POST /api/v1/<app>/<model>/<pk>/action/<name>/`` — run an object action.

Wire contract: ``docs/api-contract.md`` §5 (an addition the human reviewer
must land — see the PR/report note).

Object-level change-page actions are the django-object-actions affordance
(``ModelAdmin.change_actions`` / ``get_change_actions``). This endpoint
runs one named action against a single object. It is the per-object
counterpart to the changelist ``actions`` runner (``views/actions.py``);
both never trust the client-supplied name as a callable lookup.

Hard rules (`SECURITY.md` §3, `ACCEPTANCE.md` §3.1):

- Rule 1: Staff + ``AdminSite.has_permission`` gate.
- Rule 3: Model resolved through ``admin.site._registry`` (B-7).
- Rule 5: ``has_change_permission(request, obj)`` per-object gate — object
actions are change-shaped, matching how django-object-actions
wires them onto the change view.
- Rule 10: Object loaded through ``ModelAdmin.get_object`` /
``get_queryset`` — never ``Model.objects.all()`` (B-2).
- Rule 12: The action ``name`` is verified against the *permitted* set
(``get_change_actions``) before the callable runs; an unknown /
non-permitted name is a 404 and the callable never executes.
Action callables may raise — we catch and return a clean 400
``{ok: false, error}`` rather than a 500 (per the issue brief).
- CSRF: No ``@csrf_exempt`` — Django's middleware enforces.
"""

from __future__ import annotations

from typing import Any

from django.http import HttpRequest
from django.http import HttpResponse
from django.http import JsonResponse
from django.views.generic import View

from django_admin_react.api.object_actions import permitted_action_names
from django_admin_react.api.object_actions import resolve_action_callable
from django_admin_react.api.permissions import forbidden_response
from django_admin_react.api.permissions import is_admin_user
from django_admin_react.api.registry import get_admin_site
from django_admin_react.api.registry import resolve_model
from django_admin_react.api.writes import load_object_or_none
from django_admin_react.api.writes import not_found_response


class ObjectActionView(View):
"""``POST /api/v1/<app_label>/<model_name>/<pk>/action/<name>/``."""

http_method_names = ["post"]

def post(
self,
request: HttpRequest,
app_label: str,
model_name: str,
pk: str,
name: str,
*args: Any,
**kwargs: Any,
) -> HttpResponse:
"""Run one permitted object action against a single object.

Gates, in order:

1. ``is_admin_user`` — 403 if not authenticated active staff.
2. ``resolve_model`` — 404 if the model is unknown / unviewable.
3. ``load_object_or_none`` — 404 if the pk doesn't resolve under
the admin's queryset (rule 10) or parse-fails.
4. ``has_change_permission(request, obj)`` — per-object gate
(object actions are change-shaped).
5. ``permitted_action_names`` — 404 unless ``name`` is in the set
the admin permits for this user + object (the callable never
runs for a name not in that set).

On success the response is ``{ok: true, message?, redirect?}``: if
the callable returns a redirect ``HttpResponse`` we surface its URL
as ``redirect`` (the API itself returns 200, never a 302, so the
SPA controls navigation). If the callable raises, the response is
``{ok: false, error}`` with status 400 — never a 500.
"""
admin_site = get_admin_site()
if not is_admin_user(request, admin_site=admin_site):
return forbidden_response(request)

resolved = resolve_model(admin_site, request, app_label, model_name)
if resolved is None:
return not_found_response()
model, model_admin = resolved

obj = load_object_or_none(model, model_admin, request, pk)
if obj is None:
return not_found_response()

# Object actions are change-shaped — gate on change permission for
# this object, the same posture django-object-actions takes by
# wiring the buttons onto the change view.
if not model_admin.has_change_permission(request, obj):
return forbidden_response(request)

# Never trust the URL ``name``: it must be in the admin's own
# permitted set. ``None`` means the admin exposes no object actions
# at all → 404 (the endpoint doesn't exist for this model).
permitted = permitted_action_names(model_admin, request, obj)
if permitted is None or name not in permitted:
return not_found_response()

action_callable = resolve_action_callable(model_admin, name)
if action_callable is None:
return not_found_response()

# The action manages its own writes (it may or may not mutate), so
# we do NOT force a surrounding ``transaction.atomic()`` — matching
# django-object-actions, which calls the bound method directly. A
# raising callable is caught and returned as a clean 400, never a
# 500 (per the issue brief).
try:
result = action_callable(request, obj)
except Exception:
# The action callable raised — return a clean 400, never a 500.
# The exception text can disclose internal detail, so we surface
# only a generic, safe message (``SECURITY.md`` §3 rule 12); the
# real cause is in the consumer's server logs, not the wire.
return _json_response(
{"ok": False, "error": "The action could not be completed."},
status=400,
)

return _success_response(result)


def _success_response(result: Any) -> HttpResponse:
"""Build the ``{ok: true, message?, redirect?}`` success envelope.

A redirect ``HttpResponse`` from the callable is surfaced as a
``redirect`` URL (we never echo a 302 — the SPA navigates client-side
without a full-page reload). Any other ``HttpResponse`` is treated as
"ran successfully" with no extra payload. The django-object-actions
contract is that an action returns ``None`` (just ran) or an
``HttpResponse`` (commonly a redirect).
"""
body: dict[str, Any] = {"ok": True}
if isinstance(result, HttpResponse):
# A redirect response carries a ``Location`` header (3xx). Mirror
# the changelist action runner (``views/actions.py``): surface the
# target so the SPA navigates client-side. ``url`` is set on
# ``HttpResponseRedirect`` subclasses; the header is the fallback.
target = getattr(result, "url", None) or result.get("Location", None)
if target:
body["redirect"] = str(target)
return _json_response(body, status=200)


def _json_response(body: dict[str, Any], status: int) -> HttpResponse:
"""JSON envelope with the package's standard no-store cache header."""
response = JsonResponse(body, status=status)
response["Cache-Control"] = "no-store"
return response
Loading
Loading