Skip to content

Commit f4dabd5

Browse files
chore(release): v1.4.9 — honour ModelAdmin.actions 'target' classification (#615)
Patch — SPA honours the new `target` field from API 1.0.6.
1 parent 5f4364e commit f4dabd5

7 files changed

Lines changed: 123 additions & 49 deletions

File tree

README.md

Lines changed: 30 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -422,18 +422,38 @@ class InvoiceAdmin(admin.ModelAdmin):
422422

423423
### Add custom admin actions
424424

425+
One `actions = (...)` declaration. The API classifies each callable by signature and the SPA renders it on the right surface — **changelist** (multi-select bulk run) or **detail page** (single-object button) — automatically:
426+
425427
```python
426428
class InvoiceAdmin(admin.ModelAdmin):
427-
actions = ["mark_paid"]
429+
actions = ("mark_paid", "regenerate_pdf")
428430

431+
# Third parameter is `queryset` → batch shape.
432+
# Renders on the changelist with multi-select.
429433
@admin.action(description="Mark selected as paid")
430434
def mark_paid(self, request, queryset):
431435
queryset.update(status="paid", paid_at=timezone.now())
436+
437+
# Third parameter named `obj_id` (or annotated `str`/`int`/Model)
438+
# → detail shape. Renders as a button on the single-invoice
439+
# detail page header.
440+
@admin.action(description="Regenerate PDF")
441+
def regenerate_pdf(self, request, obj_id: str):
442+
invoice = self.model.objects.get(pk=obj_id)
443+
invoice.regenerate_pdf()
444+
self.message_user(request, f"Regenerated PDF for #{invoice.pk}.")
432445
```
433446

434-
The SPA renders a bulk-actions menu and posts to the same
435-
`ModelAdmin.actions` machinery — same signatures, same audit
436-
trail.
447+
Classifier rules (api 1.0.6+):
448+
449+
| Third parameter | Target | Where it renders |
450+
|---|---|---|
451+
| name `queryset` / `qs`, or `QuerySet` annotation | `batch` | Changelist multi-select |
452+
| name `obj_id` / `object_id` / `pk` / `id` / `object_pk` | `detail` | Detail page header |
453+
| annotation `str` / `int` / `Model` subclass | `detail` | Detail page header |
454+
| anything else | `batch` (default, preserves stock Django) | Changelist multi-select |
455+
456+
Same `@admin.action` decorator on both. Same `ModelAdmin.actions` tuple. Same audit trail. **No `django-object-actions`, no `change_actions = (...)` redeclaration** — the signature is the wire.
437457

438458
### Per-row permission gating
439459

@@ -538,14 +558,11 @@ customisations.
538558

539559
---
540560

541-
## Feature status (alpha — currently `0.2.0a*` on PyPI)
561+
## Feature status
542562

543-
The **backend** — the `ModelAdmin`-driven REST API — is the stable,
544-
complete surface and the table below tracks it. The **React SPA** that
545-
consumes it is in active development; to keep this README from drifting,
546-
per-feature *SPA* (UI) status is **not** duplicated here — it is tracked
547-
live in the [frontend implementation tracker (#160)](https://github.com/MartinCastroAlvarez/django-admin-react/issues/160)
548-
and the [project board](https://github.com/users/MartinCastroAlvarez/projects/3).
563+
All three packages are **Production / Stable** on PyPI. The
564+
`ModelAdmin`-driven REST API + the React SPA + the MCP adapter
565+
all share the v1 wire contract. Per-feature live status below.
549566

550567
| `ModelAdmin` surface | Backend (REST API) |
551568
| ------------------------------------------------------ | --------------------------------------------------------------- |
@@ -554,7 +571,7 @@ and the [project board](https://github.com/users/MartinCastroAlvarez/projects/3)
554571
| `list_filter` (boolean / choice / FK / date / Simple) ||
555572
| `date_hierarchy` ||
556573
| `list_editable` + bulk PATCH ||
557-
| `actions` (custom + bulk runner) ||
574+
| `actions` — batch + detail (signature-classified) ||
558575
| `autocomplete_fields` / `raw_id_fields` ||
559576
| `ManyToManyField` read + write ||
560577
| `inlines` (TabularInline / StackedInline) — read + write ||
@@ -569,9 +586,7 @@ and the [project board](https://github.com/users/MartinCastroAlvarez/projects/3)
569586
| OpenAPI 3.1 schema at `/api/v1/schema/` ||
570587
| PWA manifest + service worker (cache-purge on logout) ||
571588

572-
✅ = shipped in the current alpha. 🟡 = not yet built (tracked). This
573-
column is the **backend** capability only — for which surfaces the React
574-
UI renders today, see the [frontend tracker (#160)](https://github.com/MartinCastroAlvarez/django-admin-react/issues/160).
589+
✅ = shipped. 🟡 = not yet built (tracked).
575590

576591
---
577592

examples/fintech/admin.py

Lines changed: 39 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -23,39 +23,62 @@ class TransactionAdmin(admin.ModelAdmin):
2323
date_hierarchy = "posted_at"
2424
autocomplete_fields = ("account",)
2525
readonly_fields = ("reference",)
26-
# Stock Django admin actions (`@admin.action`). They surface on the
27-
# changelist (multi-pk runs) AND the SPA's detail page header
28-
# (single-pk runs) — no `django-object-actions` mixin, no
29-
# `change_actions = [...]` redeclaration. One source of truth for
30-
# everything the consumer wants to expose as an action.
31-
actions = ("mark_reconciled", "recompute_reference")
26+
# Stock Django admin actions (`@admin.action`). Same `actions = (...)`
27+
# declaration, two render surfaces — the API (1.0.6+) inspects each
28+
# callable's third-parameter NAME / ANNOTATION and classifies it:
29+
#
30+
# - `mark_reconciled(self, request, queryset)` → batch
31+
# → renders on the changelist with multi-select.
32+
# - `reprocess(self, request, obj_id: str)` → detail
33+
# → renders as a button on the single-object detail page.
34+
#
35+
# Same admin declaration. One actions tuple. Signature decides
36+
# where the SPA renders the button.
37+
actions = ("mark_reconciled", "recompute_reference", "reprocess")
3238

3339
@admin.action(description="Mark as reconciled")
3440
def mark_reconciled(self, request, queryset):
35-
"""Tag every row in the queryset as reconciled.
41+
"""Changelist (`batch`) action — operates on every selected row.
3642
37-
Demo no-op: the model has no `reconciled` flag yet — this
38-
exists so the SPA's detail page has a stock-Django-admin
39-
action to render. A real ModelAdmin would update the row
40-
and message the user.
43+
Demo no-op: the model has no `reconciled` flag yet, so this
44+
just message_users the count. Renders on the changelist
45+
because the third parameter is named `queryset`.
4146
"""
4247
count = queryset.count()
4348
self.message_user(request, f"Marked {count} transaction(s) as reconciled.")
4449

4550
@admin.action(description="Recompute reference")
4651
def recompute_reference(self, request, queryset):
47-
"""Roll the ``reference`` field on each row in the queryset.
48-
49-
Demo: bumps ``reference`` to ``TXN-RECOMP-<stamp>-<i>`` so the
50-
operator can see the action ran end-to-end on the SPA.
51-
"""
52+
"""Changelist (`batch`) action — bumps every selected row's
53+
``reference`` so the operator can see the action ran end-to-end
54+
from the changelist."""
5255
from datetime import datetime, timezone as tz
5356
stamp = datetime.now(tz.utc).strftime("%Y%m%d%H%M%S")
5457
for i, row in enumerate(queryset):
5558
row.reference = f"TXN-RECOMP-{stamp}-{i:02d}"
5659
row.save(update_fields=["reference"])
5760
self.message_user(request, f"Recomputed reference on {queryset.count()} row(s).")
5861

62+
@admin.action(description="Reprocess this transaction")
63+
def reprocess(self, request, obj_id: str):
64+
"""Detail-page (`detail`) action — operates on the single
65+
object behind the detail view.
66+
67+
Renders on the SPA's detail page header (not on the
68+
changelist) because the third parameter is annotated `str`
69+
and named `obj_id` — both signals tell the API's classifier
70+
the action expects a single object id, not a queryset.
71+
72+
Demo: writes a `reference` tag so the operator can see the
73+
action ran end-to-end without leaving the detail view.
74+
"""
75+
from datetime import datetime, timezone as tz
76+
stamp = datetime.now(tz.utc).strftime("%Y%m%d%H%M%S")
77+
row = Transaction.objects.get(pk=obj_id)
78+
row.reference = f"TXN-REPROC-{stamp}"
79+
row.save(update_fields=["reference"])
80+
self.message_user(request, f"Reprocessed transaction {row.pk}.")
81+
5982

6083
@admin.register(Statement)
6184
class StatementAdmin(admin.ModelAdmin):

frontend/apps/web/src/pages/DetailPage.tsx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -291,7 +291,20 @@ export function DetailPage({
291291
action the user isn't allowed to. On success we re-fetch the
292292
detail payload (computed/readonly fields may have changed) and
293293
navigate if the action returned a redirect. No full reload. */}
294-
{(data.object_actions ?? []).map((action) => (
294+
{/* Render ONLY `target === 'detail'` actions here
295+
(api 1.0.6+ #603 revised). The same `data.object_actions`
296+
wire field carries every `ModelAdmin.actions` entry; the
297+
signature-inspection classifier on the API marks the
298+
ones whose third parameter is a single object id as
299+
`detail` and the queryset-shaped ones as `batch`. The
300+
changelist runner endpoint handles both shapes, but we
301+
only want the single-pk-callable ones reachable from
302+
the detail page header. Back-compat: a pre-1.0.6 API
303+
omits `target` — fall through to NOT showing anything
304+
(the legacy/stock Django shape was batch-only). */}
305+
{(data.object_actions ?? [])
306+
.filter((action) => action.target === 'detail')
307+
.map((action) => (
295308
<ObjectActionButton
296309
key={action.name}
297310
action={action}

frontend/apps/web/src/pages/ListPage.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -474,7 +474,15 @@ export function ListPage() {
474474
const filters = data.filters ?? [];
475475
// Count of list_filters currently applied (drives the empty-state copy).
476476
const activeFilterCount = filters.filter((f) => activeFilters[f.name]).length;
477-
const actions = data.actions ?? [];
477+
// Changelist actions = only those classified `batch` by the API
478+
// (api 1.0.6+ #603 revised). `detail`-target actions live on the
479+
// single-object page; rendering them here would let the operator
480+
// run a single-pk-shaped callable across a multi-row selection,
481+
// which would 400. Back-compat: pre-1.0.6 API omits `target`;
482+
// treat the absence as `batch` so older servers keep working.
483+
const actions = (data.actions ?? []).filter(
484+
(a) => a.target === undefined || a.target === 'batch',
485+
);
478486
const canRunActions = actions.length > 0 && data.permissions.change;
479487

480488
// Select-all-across-pages (#386). The total matching the current filter

frontend/packages/api/src/contract.ts

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -257,12 +257,28 @@ export interface FilterDescriptor {
257257
selected?: string | null;
258258
}
259259

260-
/** One bulk action surfaced from `ModelAdmin.actions`. */
260+
/**
261+
* One action surfaced from `ModelAdmin.actions`. The API classifies
262+
* each registered callable by signature (api 1.0.6+, #603 revised):
263+
*
264+
* - `batch` — third parameter is the changelist queryset
265+
* (stock Django `def my_action(self, request, queryset)`). Renders
266+
* on the **changelist** with multi-select.
267+
* - `detail` — third parameter is a single object id (parameter
268+
* named `obj_id` / `pk` / etc., or annotated `str` / `int` /
269+
* `Model`). Renders on the **single-object detail page**.
270+
*
271+
* Older API versions (<1.0.6) omit the `target` field; callers
272+
* should default to `'batch'` for back-compat.
273+
*/
261274
export interface ActionDescriptor {
262275
name: string;
263276
label: string;
264277
description: string;
265278
requires_confirmation?: boolean;
279+
/** Render surface for this action (api 1.0.6+). Absent on older
280+
* servers; treat as `'batch'` (the legacy/stock Django shape). */
281+
target?: 'batch' | 'detail';
266282
}
267283

268284
/** Result of running a bulk action (contract §5.4). */
@@ -284,16 +300,15 @@ export interface ActionRunResponse {
284300
}
285301

286302
/**
287-
* One object-level change-page action (#236) — the django-object-actions
288-
* `change_actions` affordance, surfaced on the detail response only when
289-
* the admin opts in (duck-typed; no hard dependency). The SPA renders a
290-
* button per entry next to Edit/Delete.
303+
* One detail-page action descriptor. As of api 1.0.6+ (#603 revised),
304+
* `data.object_actions` on the detail response uses the same
305+
* descriptor shape as `data.actions` on the list response — the API
306+
* builds both from `actions_payload(...)` and classifies each by
307+
* signature (`target: 'batch' | 'detail'`). `ObjectActionDescriptor`
308+
* is kept as an alias for backwards-compat with the v1.0.2 → v1.4.7
309+
* `<ObjectActionButton>` consumer that still imports the name.
291310
*/
292-
export interface ObjectActionDescriptor {
293-
name: string;
294-
label: string;
295-
description?: string;
296-
}
311+
export type ObjectActionDescriptor = ActionDescriptor;
297312

298313
/**
299314
* Result of running one object action

poetry.lock

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "django-admin-react"
3-
version = "1.4.8"
3+
version = "1.4.9"
44
description = "A drop-in React single-page admin for Django, driven entirely by ModelAdmin."
55
authors = ["django-admin-react contributors"]
66
license = "MIT"
@@ -49,7 +49,7 @@ django = ">=5.0,<7.0"
4949
# React SPA super-layer over `django-admin-rest-api`. The package's URLs
5050
# are included by `django_admin_react.urls`, and consumers add
5151
# `"django_admin_rest_api"` to `INSTALLED_APPS` alongside this package.
52-
django-admin-rest-api = "^1.0.5"
52+
django-admin-rest-api = "^1.0.6"
5353
# `django-admin-mcp-api` — MCP-protocol adapter over the same REST API
5454
# so agents reach the SAME `ModelAdmin`-driven surface. Wire-protocol-only
5555
# layer; adds NO new functionality, permissions, or validation.

0 commit comments

Comments
 (0)