diff --git a/django_admin_react/api/object_actions.py b/django_admin_react/api/object_actions.py new file mode 100644 index 0000000..16d9f0a --- /dev/null +++ b/django_admin_react/api/object_actions.py @@ -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 +`_ 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 "" diff --git a/django_admin_react/api/urls.py b/django_admin_react/api/urls.py index a14338d..c0ca141 100644 --- a/django_admin_react/api/urls.py +++ b/django_admin_react/api/urls.py @@ -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.registry import RegistryView from django_admin_react.api.views.schema import SchemaView @@ -156,6 +157,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 ```` instance route below so it isn't + # swallowed as part of the pk. 404s for any model whose admin exposes + # no object actions. + path( + "///action//", + ObjectActionView.as_view(), + name="object_action", + ), path( "///", InstanceView.as_view(), diff --git a/django_admin_react/api/views/detail.py b/django_admin_react/api/views/detail.py index d0ce51d..4ae8197 100644 --- a/django_admin_react/api/views/detail.py +++ b/django_admin_react/api/views/detail.py @@ -32,6 +32,7 @@ from django.views.generic import View 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 @@ -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,15 @@ def _build_payload( "inlines": inlines_payload(model_admin, obj, request, admin_site), "view_on_site_url": _view_on_site_url(model_admin, obj), } + # 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 def _view_on_site_url(model_admin: ModelAdmin, obj: Model) -> str | None: diff --git a/django_admin_react/api/views/object_action.py b/django_admin_react/api/views/object_action.py new file mode 100644 index 0000000..4ad12bb --- /dev/null +++ b/django_admin_react/api/views/object_action.py @@ -0,0 +1,159 @@ +"""``POST /api/v1////action//`` — 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////action//``.""" + + 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 diff --git a/frontend/apps/web/src/pages/DetailPage.tsx b/frontend/apps/web/src/pages/DetailPage.tsx index 5609b4b..0cc562e 100644 --- a/frontend/apps/web/src/pages/DetailPage.tsx +++ b/frontend/apps/web/src/pages/DetailPage.tsx @@ -18,6 +18,7 @@ import { createObject, deleteObject, fetchDeletePreview, + runObjectAction, updateObject, useApiClient, useDetail, @@ -28,6 +29,7 @@ import { type InlineDescriptor, type InlineWriteItem, type InlineWritePayload, + type ObjectActionDescriptor, type WriteValue, } from '@dar/data'; import { Breadcrumb, Button, Card, EmptyState, Modal, Table } from '@dar/ui'; @@ -243,6 +245,30 @@ export function DetailPage() { View on site )} + {/* Object-level change-page actions (#236) — django-object-actions + `change_actions` parity. The backend re-validates the action + name against its permitted set, so a button can never run an + action the user isn't allowed to. On success we re-fetch the + detail payload (computed/readonly fields may have changed) and + navigate if the action returned a redirect. No full reload. */} + {(data.object_actions ?? []).map((action) => ( + + runObjectAction({ client, appLabel, modelName, pk, name: action.name }) + } + onSuccess={async (message, redirect) => { + if (redirect) { + navigate(redirect); + return; + } + await refresh(); + toast.success(message || 'Done'); + }} + onError={(message) => toast.error(message)} + /> + ))} {canChange && ( + ); +} + // Which save-flow button was pressed (Django parity, #154). The parent // routes navigation per action; the form only builds + submits. type SaveAction = 'save' | 'continue' | 'addAnother' | 'saveAsNew'; diff --git a/frontend/packages/api/src/client.ts b/frontend/packages/api/src/client.ts index aa3f411..d809416 100644 --- a/frontend/packages/api/src/client.ts +++ b/frontend/packages/api/src/client.ts @@ -18,6 +18,7 @@ import type { HistoryResponse, ListResponse, LoginResponse, + ObjectActionRunResponse, RegistryResponse, UpdatePayload, } from './contract'; @@ -281,6 +282,26 @@ export class ApiClient { ); } + /** + * Run one object-level change-page action (#236) against a single + * object (`POST ///action//`). The backend + * re-resolves `name` through the admin's permitted `get_change_actions` + * set — the SPA name is never trusted as a callable lookup. CSRF is + * sent like other unsafe calls. Returns `{ok, message?, redirect?}`. + */ + runObjectAction( + appLabel: string, + modelName: string, + pk: string | number, + name: string, + ): Promise { + return this.request( + 'POST', + `${appLabel}/${modelName}/${pk}/action/${name}/`, + {}, + ); + } + /** * Typeahead for a high-cardinality FK picker (contract §3.2). The * `appLabel`/`modelName` are the **target** model's; results are diff --git a/frontend/packages/api/src/contract.ts b/frontend/packages/api/src/contract.ts index ab1c7af..2c75f14 100644 --- a/frontend/packages/api/src/contract.ts +++ b/frontend/packages/api/src/contract.ts @@ -202,6 +202,31 @@ export interface ActionRunResponse { redirect?: string; } +/** + * One object-level change-page action (#236) — the django-object-actions + * `change_actions` affordance, surfaced on the detail response only when + * the admin opts in (duck-typed; no hard dependency). The SPA renders a + * button per entry next to Edit/Delete. + */ +export interface ObjectActionDescriptor { + name: string; + label: string; + description?: string; +} + +/** + * Result of running one object action + * (`POST ///action//`, #236). `ok` is false when the + * action callable raised (the API returns 400, never a 500). `redirect` + * carries the target URL when the callable returned a redirect response — + * the SPA navigates client-side rather than following a 302. + */ +export interface ObjectActionRunResponse { + ok: boolean; + message?: string; + redirect?: string; +} + /** One cascading model in a delete preview: `{model, count}`. */ export interface DeleteCascadeEntry { /** `verbose_name_plural` of the cascading model. */ @@ -420,6 +445,14 @@ export interface DetailResponse { * object's get_absolute_url). `null` when not applicable. Optional for * back-compat with older backends. */ view_on_site_url?: string | null; + /** + * Object-level change-page actions (#236) — the django-object-actions + * `change_actions` affordance. Present (possibly `[]`) only when the + * admin exposes object actions; absent for a plain-Django admin. The + * SPA renders a button per entry; clicking runs + * `POST ///action//`. + */ + object_actions?: ObjectActionDescriptor[]; } /** diff --git a/frontend/packages/data/src/index.ts b/frontend/packages/data/src/index.ts index d2c52c1..44a91cb 100644 --- a/frontend/packages/data/src/index.ts +++ b/frontend/packages/data/src/index.ts @@ -45,6 +45,8 @@ export type { ListResponse, ListRow, LoginResponse, + ObjectActionDescriptor, + ObjectActionRunResponse, Permissions, RegistryAppEntry, RegistryModelEntry, @@ -64,8 +66,20 @@ export type { ListState } from './list-context'; export { useDetail } from './detail-context'; export type { DetailState } from './detail-context'; -export { createObject, updateObject, deleteObject, fetchDeletePreview } from './mutations'; -export type { CreateArgs, UpdateArgs, DeleteArgs, DeletePreviewArgs } from './mutations'; +export { + createObject, + updateObject, + deleteObject, + fetchDeletePreview, + runObjectAction, +} from './mutations'; +export type { + CreateArgs, + UpdateArgs, + DeleteArgs, + DeletePreviewArgs, + RunObjectActionArgs, +} from './mutations'; export { renderValue, isHtmlValue, isForeignKeyValue, isFileValue } from './format'; diff --git a/frontend/packages/data/src/mutations.ts b/frontend/packages/data/src/mutations.ts index 65ed057..7d9b70f 100644 --- a/frontend/packages/data/src/mutations.ts +++ b/frontend/packages/data/src/mutations.ts @@ -5,7 +5,13 @@ // - debounced batching for rapid edits; // - cache invalidation on success. -import type { ApiClient, CreatePayload, DeletePreviewResponse, UpdatePayload } from '@dar/api'; +import type { + ApiClient, + CreatePayload, + DeletePreviewResponse, + ObjectActionRunResponse, + UpdatePayload, +} from '@dar/api'; export interface CreateArgs { client: ApiClient; @@ -31,6 +37,16 @@ export interface DeleteArgs { export type DeletePreviewArgs = DeleteArgs; +export interface RunObjectActionArgs { + client: ApiClient; + appLabel: string; + modelName: string; + pk: string | number; + /** The object-action name, re-validated server-side against the admin's + * permitted `get_change_actions` set. */ + name: string; +} + export function createObject(args: CreateArgs) { return args.client.create(args.appLabel, args.modelName, args.payload); } @@ -46,3 +62,8 @@ export function deleteObject(args: DeleteArgs) { export function fetchDeletePreview(args: DeletePreviewArgs): Promise { return args.client.deletePreview(args.appLabel, args.modelName, args.pk); } + +/** Run one object-level change-page action (#236). */ +export function runObjectAction(args: RunObjectActionArgs): Promise { + return args.client.runObjectAction(args.appLabel, args.modelName, args.pk, args.name); +} diff --git a/tests/test_object_actions.py b/tests/test_object_actions.py new file mode 100644 index 0000000..5e72c2a --- /dev/null +++ b/tests/test_object_actions.py @@ -0,0 +1,403 @@ +"""Tests for object-level change-page actions (Issue #236). + +These cover both halves of the feature: + +- The detail payload's optional ``object_actions`` block (omitted for a + plain-Django admin, present + permission-gated when the admin exposes + the django-object-actions ``change_actions`` / ``get_change_actions`` + contract). +- The ``POST /api/v1////action//`` runner, with the + mandatory security matrix from CLAUDE.md §6. + +We never add ``django-object-actions`` as a dependency — instead each +test wires the *same contract* onto a registered admin (``auth.User``): +``change_actions`` (a list of names), a ``get_change_actions`` hook that +filters by permission, and the action callables bound as methods on the +admin instance (django-object-actions binds them the same way, so +``getattr(model_admin, name)`` resolves the bound callable, called as +``method(request, obj)``). +""" + +from __future__ import annotations + +from contextlib import contextmanager +from contextlib import suppress + +import pytest +from django.contrib import admin +from django.contrib.auth import get_user_model +from django.http import HttpResponseRedirect +from django.test import Client + +User = get_user_model() + +DETAIL_URL = "/admin-react/api/v1/auth/user/{pk}/" +ACTION_URL = "/admin-react/api/v1/auth/user/{pk}/action/{name}/" + + +# --------------------------------------------------------------------------- # +# Test scaffolding — emulate the django-object-actions admin contract # +# --------------------------------------------------------------------------- # +@contextmanager +def admin_attr(model_cls, **values): + """Temporarily set attributes (incl. bound methods) on a ModelAdmin. + + Functions are bound to the admin instance with ``__get__`` so that + ``getattr(model_admin, name)`` returns a bound method called as + ``method(request, obj)`` — exactly how django-object-actions exposes + its actions. + """ + model_admin = admin.site._registry[model_cls] + sentinel = object() + originals: dict = {} + try: + for name, value in values.items(): + originals[name] = model_admin.__dict__.get(name, sentinel) + bound = value.__get__(model_admin) if callable(value) else value + setattr(model_admin, name, bound) + yield + finally: + for name, original in originals.items(): + if original is sentinel: + with suppress(AttributeError): + delattr(model_admin, name) + else: + setattr(model_admin, name, original) + + +# An object action: ``method(self, request, obj)``. Marks the user +# inactive so the test can assert the side effect actually happened. +def _deactivate(self, request, obj): # noqa: ANN001, ANN202, ARG001 + """Deactivate this account.""" + obj.is_active = False + obj.save(update_fields=["is_active"]) + + +_deactivate.label = "Deactivate" # django-object-actions ``label`` attr +# ``allowed_permissions`` style marker the get_change_actions hook below +# uses to gate this action behind change permission. +_deactivate.allowed_permissions = ("change",) + + +def _redirecting(self, request, obj): # noqa: ANN001, ANN202, ARG001 + """Go somewhere else after running.""" + return HttpResponseRedirect("/admin-react/auth/user/") + + +def _boom(self, request, obj): # noqa: ANN001, ANN202, ARG001 + """Raise on purpose.""" + raise ValueError("the action blew up") + + +def _make_get_change_actions(*action_names): + """Build a ``get_change_actions`` hook gated by ``allowed_permissions``. + + Mirrors django-object-actions' permission filtering: an action whose + ``allowed_permissions`` the user fails is dropped from the returned + list, so the SPA never sees it and the runner 404s it. + """ + + def get_change_actions(self, request, context, object_id): # noqa: ANN001, ANN202, ARG001 + allowed = [] + for name in action_names: + tool = getattr(self, name, None) + perms = getattr(tool, "allowed_permissions", ()) + if all( + getattr(self, f"has_{p}_permission")(request) for p in perms + ): + allowed.append(name) + return allowed + + return get_change_actions + + +def _objaction_admin(*, with_hook=True): + """The admin-attr kwargs that wire up the object-action contract.""" + kwargs = { + "change_actions": ["_deactivate"], + "_deactivate": _deactivate, + } + if with_hook: + kwargs["get_change_actions"] = _make_get_change_actions("_deactivate") + return kwargs + + +def _make_user(username: str = "subject", *, is_active: bool = True) -> User: + return User.objects.create_user( + username=username, + password="initial-password-xyz", # noqa: S106 + email=f"{username}@example.com", + is_active=is_active, + ) + + +def _post(client: Client, pk: object, name: str) -> object: + return client.post(ACTION_URL.format(pk=pk, name=name), content_type="application/json") + + +# --------------------------------------------------------------------------- # +# Detail payload: object_actions block # +# --------------------------------------------------------------------------- # +@pytest.mark.django_db +def test_detail_omits_object_actions_for_plain_admin(superuser_client: Client) -> None: + """A plain-Django admin (no change_actions) emits NO object_actions key.""" + u = _make_user() + body = superuser_client.get(DETAIL_URL.format(pk=u.pk)).json() + assert "object_actions" not in body + + +@pytest.mark.django_db +def test_detail_exposes_object_actions(superuser_client: Client) -> None: + """An admin with the django-object-actions contract surfaces the + action's name + label + description.""" + u = _make_user() + with admin_attr(User, **_objaction_admin()): + body = superuser_client.get(DETAIL_URL.format(pk=u.pk)).json() + assert "object_actions" in body + actions = {a["name"]: a for a in body["object_actions"]} + assert "_deactivate" in actions + assert actions["_deactivate"]["label"] == "Deactivate" # ``label`` attr wins + assert actions["_deactivate"]["description"] == "Deactivate this account." + + +@pytest.mark.django_db +def test_detail_humanizes_label_without_label_attr(superuser_client: Client) -> None: + """When the action has no ``label`` attr, the name is humanized + (``send_welcome_email`` → ``Send welcome email``), matching + django-object-actions' ``capfirst(name.replace("_", " "))``.""" + + def send_welcome_email(self, request, obj): # noqa: ANN001, ANN202, ARG001 + return None + + with admin_attr( + User, + change_actions=["send_welcome_email"], + send_welcome_email=send_welcome_email, + get_change_actions=_make_get_change_actions("send_welcome_email"), + ): + u = _make_user() + body = superuser_client.get(DETAIL_URL.format(pk=u.pk)).json() + action = next(a for a in body["object_actions"] if a["name"] == "send_welcome_email") + assert action["label"] == "Send welcome email" # capfirst(name.replace("_", " ")) + + +@pytest.mark.django_db +def test_detail_change_actions_attr_without_hook(superuser_client: Client) -> None: + """An admin that declares ``change_actions`` but no ``get_change_actions`` + hook still surfaces the actions (gated at run time by change perm).""" + u = _make_user() + with admin_attr(User, **_objaction_admin(with_hook=False)): + body = superuser_client.get(DETAIL_URL.format(pk=u.pk)).json() + assert {a["name"] for a in body["object_actions"]} == {"_deactivate"} + + +@pytest.mark.django_db +def test_detail_filters_unpermitted_actions(superuser_client: Client) -> None: + """An action the user is not permitted to run (per get_change_actions) + does not appear in object_actions.""" + u = _make_user() + + def _deny_all(self, request, context, object_id): # noqa: ANN001, ANN202, ARG001 + return [] + + with admin_attr( + User, + change_actions=["_deactivate"], + _deactivate=_deactivate, + get_change_actions=_deny_all, + ): + body = superuser_client.get(DETAIL_URL.format(pk=u.pk)).json() + assert body["object_actions"] == [] + + +# --------------------------------------------------------------------------- # +# §6 mandatory matrix on the runner # +# --------------------------------------------------------------------------- # +@pytest.mark.django_db +def test_anonymous_action_unauthorized(anon_client: Client) -> None: + u = _make_user() + with admin_attr(User, **_objaction_admin()): + response = _post(anon_client, u.pk, "_deactivate") + assert response.status_code in (302, 403) + u.refresh_from_db() + assert u.is_active is True # never ran + + +@pytest.mark.django_db +def test_non_staff_action_forbidden(user_client: Client) -> None: + u = _make_user() + with admin_attr(User, **_objaction_admin()): + response = _post(user_client, u.pk, "_deactivate") + assert response.status_code == 403 + u.refresh_from_db() + assert u.is_active is True + + +@pytest.mark.django_db +def test_staff_without_change_permission_forbidden(superuser_client: Client) -> None: + """A user without change permission on the object is 403 — object + actions are change-shaped.""" + u = _make_user() + from tests.helpers import admin_override + + with ( + admin_attr(User, **_objaction_admin()), + admin_override(User, has_change_permission=lambda self, request, obj=None: False), + ): + response = _post(superuser_client, u.pk, "_deactivate") + assert response.status_code == 403 + u.refresh_from_db() + assert u.is_active is True # callable never ran + + +@pytest.mark.django_db +def test_staff_with_permission_runs_action(superuser_client: Client) -> None: + u = _make_user() + with admin_attr(User, **_objaction_admin()): + response = _post(superuser_client, u.pk, "_deactivate") + assert response.status_code == 200, response.content + assert response.json() == {"ok": True} + u.refresh_from_db() + assert u.is_active is False # the action ran + + +@pytest.mark.django_db +def test_unknown_action_name_not_found(superuser_client: Client) -> None: + u = _make_user() + with admin_attr(User, **_objaction_admin()): + response = _post(superuser_client, u.pk, "_does_not_exist") + assert response.status_code == 404 + + +@pytest.mark.django_db +def test_action_not_permitted_is_not_found(superuser_client: Client) -> None: + """An action listed in ``change_actions`` but dropped by + ``get_change_actions`` (e.g. a per-action permission fail) → 404, and + the callable never runs. + + Change permission on the object stays True (so the run reaches the + permitted-set gate, not the change-perm gate) — isolating the + fail-closed behaviour of the permitted-set check itself. + """ + u = _make_user() + + def _hook_drops_it(self, request, context, object_id): # noqa: ANN001, ANN202, ARG001 + # ``_deactivate`` is declared in change_actions but the hook + # returns it as NOT permitted for this user. + return [] + + with admin_attr( + User, + change_actions=["_deactivate"], + _deactivate=_deactivate, + get_change_actions=_hook_drops_it, + ): + response = _post(superuser_client, u.pk, "_deactivate") + assert response.status_code == 404 + u.refresh_from_db() + assert u.is_active is True # callable never ran — no privilege bypass + + +@pytest.mark.django_db +def test_unregistered_model_not_found(superuser_client: Client) -> None: + response = superuser_client.post( + "/admin-react/api/v1/auth/nope/1/action/_deactivate/", + content_type="application/json", + ) + assert response.status_code == 404 + + +@pytest.mark.django_db +def test_nonexistent_pk_not_found(superuser_client: Client) -> None: + with admin_attr(User, **_objaction_admin()): + response = _post(superuser_client, 999999, "_deactivate") + assert response.status_code == 404 + + +@pytest.mark.django_db +def test_csrf_missing_forbidden() -> None: + """An action POST without a CSRF token is a 403 from middleware — the + view is not csrf_exempt.""" + u = _make_user() + actor = User.objects.create_superuser( + username="csrf_root_oa", + password="test-only-csrf-root-oa", # noqa: S106 + email="csrf-oa@example.com", + ) + client = Client(enforce_csrf_checks=True) + client.force_login(actor) + with admin_attr(User, **_objaction_admin()): + response = client.post( + ACTION_URL.format(pk=u.pk, name="_deactivate"), + content_type="application/json", + ) + assert response.status_code == 403 + u.refresh_from_db() + assert u.is_active is True + + +# --------------------------------------------------------------------------- # +# Feature-specific # +# --------------------------------------------------------------------------- # +@pytest.mark.django_db +def test_action_returning_redirect_is_surfaced(superuser_client: Client) -> None: + """When the callable returns a redirect HttpResponse, its URL is in + ``redirect`` and the API response is 200 (never a 302).""" + u = _make_user() + with admin_attr( + User, + change_actions=["_redirecting"], + _redirecting=_redirecting, + get_change_actions=_make_get_change_actions("_redirecting"), + ): + response = _post(superuser_client, u.pk, "_redirecting") + assert response.status_code == 200 + body = response.json() + assert body["ok"] is True + assert body["redirect"] == "/admin-react/auth/user/" + + +@pytest.mark.django_db +def test_action_that_raises_is_clean_400(superuser_client: Client) -> None: + """A raising action callable → ``{ok: false, error}`` 400, never a 500.""" + u = _make_user() + with admin_attr( + User, + change_actions=["_boom"], + _boom=_boom, + get_change_actions=_make_get_change_actions("_boom"), + ): + response = _post(superuser_client, u.pk, "_boom") + assert response.status_code == 400 + body = response.json() + assert body["ok"] is False + assert "error" in body + # The internal exception text never leaks onto the wire. + assert "blew up" not in response.content.decode("utf-8") + + +@pytest.mark.django_db +def test_action_response_has_no_store_cache(superuser_client: Client) -> None: + u = _make_user() + with admin_attr(User, **_objaction_admin()): + response = _post(superuser_client, u.pk, "_deactivate") + assert response["Cache-Control"] == "no-store" + + +@pytest.mark.django_db +def test_get_method_not_allowed(superuser_client: Client) -> None: + u = _make_user() + with admin_attr(User, **_objaction_admin()): + response = superuser_client.get(ACTION_URL.format(pk=u.pk, name="_deactivate")) + assert response.status_code == 405 + + +@pytest.mark.django_db +def test_runner_404s_when_admin_has_no_object_actions(superuser_client: Client) -> None: + """A plain admin (no change_actions / get_change_actions) → the action + endpoint 404s (the affordance doesn't exist for this model).""" + u = _make_user() + response = _post(superuser_client, u.pk, "_deactivate") + assert response.status_code == 404 + u.refresh_from_db() + assert u.is_active is True