feat(api): object-level change-page actions (#236) — DRAFT, human review required#427
feat(api): object-level change-page actions (#236) — DRAFT, human review required#427MartinCastroAlvarez wants to merge 1 commit into
Conversation
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
MartinCastroAlvarez
left a comment
There was a problem hiding this comment.
Security review (Security lead) — secure; approving.
Reviewed the object-action run path against the action-security model:
- ✅ Gates in correct order:
is_admin_user→resolve_model(404) →load_object_or_none(usesget_queryset— Rule 10) →has_change_permission(request, obj)(per-object) →name in permitted_action_names(...)(404 otherwise). - ✅ No arbitrary callable invocation: the URL
nameis checked against the admin's permitted set beforeresolve_action_callable(getattr); the docstring + flow make the name a vetted lookup, never trusted raw. - ✅ Permission filtering: the primary path delegates to django-object-actions'
get_change_actions(request, ctx, pk), which applies each action's declared permission internally. - ✅ Leak-safe errors: a raising callable → generic 400 ("could not be completed"), real cause to logs only (rule 12) — never a 500.
- ✅ CSRF: POST-only, no
@csrf_exempt. Redirect responses surfaced as aredirectURL (200, SPA navigates). - ✅ Frontend: the only
@dar/apiimport is in@dar/data/mutations.ts(the legal consumer — boundary intact); nodangerouslySetInnerHTML; labels render as escaped children.
One LOW defense-in-depth note (non-blocking): the change_actions fallback (admin exposes change_actions but not get_change_actions) surfaces declared names without applying per-action allowed_permissions — only has_change_permission gates. Standard django-object-actions usage always provides get_change_actions (the mixin), so this only affects a hand-rolled config, but for parity with the bulk/changelist action endpoints it'd be worth filtering the fallback by allowed_permissions too. Filing a LOW follow-up; not a merge blocker.
No objections — merging.
|
Architect / security review (not an approval — this is a Tier-5 draft; a human + the Read
Two notes for whoever lands it:
Blocking-for-merge (author already flagged): the §4 ( |
martin-castro-laminr-ai
left a comment
There was a problem hiding this comment.
Review — architect + security (cannot merge: human-gated)
Reviewed api/object_actions.py + api/views/object_action.py as security-expert + architect. The security design is sound and I'd approve the approach:
[S] gates verified: is_admin_user → resolve_model → load_object_or_none (admin queryset, rule 10) → per-object has_change_permission → name ∈ permitted set before the callable runs. The client-supplied name is never used as a callable lookup — it's validated against get_change_actions(...) (the permission-aware hook), exactly mirroring views/actions.py's _filter_actions_by_permissions discipline. Unknown/non-permitted name → 404, callable never executes. CSRF enforced (no @csrf_exempt). A raising callable → clean {ok:false} 400, never a 500/stack-trace leak. Redirect surfaced as 200-{redirect} (SPA controls nav), consistent with the changelist runner. ✅ This is the right per-object counterpart to §5.4.
Two things before a human lands it:
- The branch is
CONFLICTING— needs a rebase onto currentmain. - The stale caveat: the PR notes a "pre-existing
test_logentry.py::test_bulk_patch_emits_one_change_per_rowfailure on clean HEAD." That regression has since been resolved onmainand the suite is now green server-side (the CI test gate #506 just landed and runspyteston every PR). After the rebase, that note no longer applies — and #506 means this PR will now get a realBackend (pytest)+Frontendcheck, so please re-push to trigger them.
Follow-up that depends on this: #455 (apply per-action allowed_permissions in the bare-change_actions fallback, defense-in-depth) is blocked on this landing — object_actions.py doesn't exist on main yet.
Remaining human-only steps (Tier 5): the docs/api-contract.md §4 + new §5 endpoint edits (deliberately omitted here), then un-draft. Design + security are ready; this is a high-value top swap-blocker (#236/#160).
Why
This is the top remaining swap blocker (#160). Validated against a live, action-heavy consumer admin: the detail payload exposed no object-actions, so operational change-page actions (the
django-object-actionschange_actionspattern — reprocess / re-run / regenerate-style buttons) could not be run in the SPA at all. Read/CRUD parity is otherwise clean.What
Object-level change-page actions, duck-typed against
django-object-actionswith no new dependency (plain-Django admins are a graceful no-op — the key is omitted).Backend
api/object_actions.py— resolver.permitted_action_names()prefersget_change_actions(request, {}, str(pk))(django-object-actions' permission-aware hook → authoritative permitted set), falls back to thechange_actionsattribute, returnsNonewhen neither exists. A raising hook degrades to[](never 500).api/views/object_action.py—POST /api/v1/<app>/<model>/<pk>/action/<name>/. Gates: staff →resolve_model→ queryset-load → per-objecthas_change_permission→namemust be in the permitted set (else 404; URL name never trusted as a lookup). Callable invokedmethod(request, obj); redirect surfaced as{redirect}(HTTP 200, no 302); a raising callable → generic{ok:false}400 (no info leak, no 500); CSRF enforced (not exempt);Cache-Control: no-store.api/views/detail.py— addsobject_actionsto the payload when the admin opts in.Frontend
contract.ts(source-of-truth for the two contract shapes),client.tsrunObjectAction,@dar/datamutation,DetailPage.tsxbuttons → async run, re-fetch on success, toast, followredirect; no full-page reload.Contract additions a HUMAN must land in
docs/api-contract.md(Tier 5)object_actions?: [{name, label, description}](present only when the admin opts in).POST /api/v1/<app>/<model>/<pk>/action/<name>/→{ok: boolean, message?, redirect?}(sibling to the §5.4 changelist action runner).Tests / validation
poetry run pytest tests/test_object_actions.py -q→ 19 passed (full matrix: anon→403, non-staff→403, staff w/o perm→403, staff w/ perm→200+ran, unknown name→404, unregistered model→404, CSRF missing→403, redirect surfaced, raising callable→400).pnpm -r typecheckclean;pnpm lint:js --max-warnings 0clean; vitest 43 pass.test_logentry.py::test_bulk_patch_emits_one_change_per_row(bulk-PATCH LogEntry path) — reproduces on clean HEAD; filing separately.Refs #236