diff --git a/CHANGELOG.md b/CHANGELOG.md index a83fad45..9ef25b30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.10.0] — 2026-06-02 + +### Added +- **`examples/jobs` — custom-form / legacy-iframe fixture app.** A single + `Job` model whose `ModelAdmin` exercises the request-driven custom-view + + custom-template pattern using *only* documented Django hooks + (`formfield_for_dbfield`, an admin `action`, a `change_view` branch, a + hand-rolled dual-listbox template) — no SPA-specific API. Proves the two + rendering paths end-to-end: Path A (`/admin-react/jobs/job//change/`) + renders the stock form-spec with the large-textarea `metadata` widget; + Path B (`?run_custom=1`) returns `renderer: "legacy-iframe"` and the SPA + embeds the legacy page in an iframe inside its own chrome. Backend tests + cover both paths and the ordered POST contract (#659). + +### Changed +- **Raised dependency floors for the cross-repo custom-form contract:** + `django-admin-rest-api` → `^1.5.0` (broadened `legacy-iframe` detection + for request-driven custom views, #59) and `django-admin-mcp-api` → + `>=1.3.0,<2.0.0` (matching MCP release, #70). The SPA's `LegacyIframe` + renderer (#659) already consumes the `legacy-iframe` discriminator. + ## [1.9.0] — 2026-06-01 ### Added diff --git a/examples/jobs/README.md b/examples/jobs/README.md new file mode 100644 index 00000000..afeda81d --- /dev/null +++ b/examples/jobs/README.md @@ -0,0 +1,62 @@ +# examples/jobs — Custom-form / legacy-iframe demo app + +A single `Job` model whose `ModelAdmin` deliberately exercises the +**request-driven custom-view + custom-template** pattern that real Django +admins use — and which the React SPA cannot render from the JSON form-spec. +The point of this fixture: it uses *only* documented `ModelAdmin` hooks +(`formfield_for_dbfield`, an admin `action`, `change_view`, a custom +template). No django-admin-react / -rest-api / -mcp-specific API appears +anywhere. If this app works on `/admin-react/`, any legacy `/admin/` +ModelAdmin works too — with at most a single-view iframe fallback. + +## What's here + +- `models.py` — `Job(name, metadata: JSONField, status)`. +- `admin.py` — `JobAdmin` + `get_step_registry()`. +- `templates/admin/jobs/job/run_custom.html` — the dual-listbox UI. +- `tests/test_admin.py` — backend tests for both render paths and the + ordered POST contract. + +## The two rendering paths + +**Path A — `/admin-react/jobs/job//change/`** (no query) + +The stock change form. `formfield_for_dbfield` swaps `metadata` to a large +textarea, so the form-spec endpoint returns `widget.kind == "textarea"` with +`vLargeTextField` in `widget.attrs.class`. The SPA renders this natively and +it matches the legacy `/admin/` change form field-for-field. + +**Path B — `/admin-react/jobs/job//change/?run_custom=1`** + +`JobAdmin.change_view` branches on `request.GET` and `render()`s a hand-rolled +dual-listbox template — not a `ModelForm`, not fieldsets. There is no +form-spec to serialise, so the resolver returns: + +```json +{ "renderer": "legacy-iframe", + "legacy_url": "/admin/legacy/jobs/job//change/?run_custom=1" } +``` + +The SPA renders its breadcrumb / sidebar / toolbar from the spec, then embeds +the legacy URL in an iframe for the body. The custom POST contract +(`request.POST.getlist("selected_steps")`, ordered) is preserved. + +## Bonus: action → redirect → variant + +The **Run (Custom)** admin action returns an `HttpResponseRedirect` to +`?run_custom=1`. The SPA action runner follows the redirect (the +action-redirect contract, #620), after which Path B kicks in. + +## How it's validated across the three packages + +| Layer | What it asserts | Where | +|-------|-----------------|-------| +| rest-api | Path A → `form-spec` w/ textarea; Path B → `legacy-iframe` | `tests/test_form_spec.py` | +| mcp | `admin.form_spec` forwards the `legacy-iframe` discriminator | `tests/test_integration.py` | +| react | SPA renders `LegacyIframe` on `renderer == "legacy-iframe"` | `frontend` + this app | + +## Run the backend tests + +```bash +poetry run python examples/project/manage.py test examples.jobs +``` diff --git a/examples/jobs/__init__.py b/examples/jobs/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/examples/jobs/admin.py b/examples/jobs/admin.py new file mode 100644 index 00000000..f49039d1 --- /dev/null +++ b/examples/jobs/admin.py @@ -0,0 +1,106 @@ +"""``JobAdmin`` — the custom-form fixture that proves the legacy-iframe escape. + +This admin uses *only* documented Django ``ModelAdmin`` hooks — no +django-admin-react / -rest-api / -mcp-specific API. The point: if this runs +on the React admin at ``/admin-react/`` with at most a single-view iframe +fallback, then any legacy ``/admin/`` ModelAdmin does too. + +Two rendering paths: + +* **Path A** — ``/admin-react/jobs/job//change/`` (no query). The stock + change form, fully describable by the form-spec contract. + ``formfield_for_dbfield`` swaps ``metadata`` to a large textarea, which + the SPA renders natively from ``widget.kind == "textarea"``. +* **Path B** — ``?run_custom=1``. ``change_view`` returns a hand-rolled + dual-listbox template (not a ModelForm, not fieldsets). The form-spec + resolver detects the custom render and returns + ``{renderer: "legacy-iframe", legacy_url: …}``; the SPA embeds the legacy + page in an iframe inside its own chrome. +""" + +from __future__ import annotations + +from django.contrib import admin +from django.contrib import messages +from django.http import HttpResponseRedirect +from django.shortcuts import redirect +from django.shortcuts import render +from django.urls import reverse + +from examples.jobs.models import Job + + +def get_step_registry() -> list[dict]: + """The catalogue of pipeline steps a Job can run (plain demo data).""" + return [ + {"name": "fetch", "label": "Fetch inputs", "default_order": 1, "is_default": True}, + {"name": "validate", "label": "Validate", "default_order": 2, "is_default": True}, + {"name": "transform", "label": "Transform", "default_order": 3, "is_default": True}, + {"name": "dry_run", "label": "Dry run", "default_order": 4, "is_default": False}, + {"name": "notify", "label": "Notify stakeholders", "default_order": 5, "is_default": False}, + {"name": "archive", "label": "Archive outputs", "default_order": 6, "is_default": False}, + ] + + +@admin.register(Job) +class JobAdmin(admin.ModelAdmin): + list_display = ("name", "status") + actions = ["run_with_custom_steps"] + + # 1) request-aware widget override on a single DB field (Path A). + def formfield_for_dbfield(self, db_field, request, **kwargs): # noqa: ANN001 + if db_field.name == "metadata": + kwargs["widget"] = admin.widgets.AdminTextareaWidget(attrs={"class": "vLargeTextField"}) + return super().formfield_for_dbfield(db_field, request, **kwargs) + + # 2) action that redirects to the ?run_custom=1 variant of the change page. + @admin.action(description="Run (Custom)") + def run_with_custom_steps(self, request, queryset): # noqa: ANN001 + if queryset.count() != 1: + self.message_user(request, "Pick exactly one row.", level=messages.ERROR) + return None + obj = queryset.get() + return HttpResponseRedirect( + reverse("admin:jobs_job_change", args=[obj.pk]) + "?run_custom=1" + ) + + # 3) change_view override branching on request.GET. + def change_view(self, request, object_id, form_url="", extra_context=None): # noqa: ANN001 + if request.GET.get("run_custom") == "1": + return self.run_custom_view(request, object_id) + return super().change_view(request, object_id, form_url, extra_context) + + # 4) custom view rendering a custom template — not a ModelForm / fieldsets. + def run_custom_view(self, request, object_id): # noqa: ANN001 + obj = self.get_object(request, object_id) + + if request.method == "POST": + selected = request.POST.getlist("selected_steps") # ordered list + if not selected: + messages.error(request, "Pick at least one step.") + return redirect(request.get_full_path()) + messages.success(request, f"Queued {' → '.join(selected)}") + return redirect("admin:jobs_job_change", object_id) + + all_steps = get_step_registry() + selected_steps = sorted( + (s for s in all_steps if s["is_default"]), + key=lambda s: s["default_order"], + ) + available_steps = [s for s in all_steps if not s["is_default"]] + + context = dict( + self.admin_site.each_context(request), + title=f"Configure Step Sequence: {obj.name}", + item_type="Job", + item_name=obj.name, + obj=obj, + available_steps=available_steps, + selected_steps=selected_steps, + all_steps=sorted(all_steps, key=lambda s: s["default_order"]), + back_url=reverse("admin:jobs_job_changelist"), + cancel_url=reverse("admin:jobs_job_change", args=[obj.pk]), + opts=self.model._meta, + preserved_filters=self.get_preserved_filters(request), + ) + return render(request, "admin/jobs/job/run_custom.html", context) diff --git a/examples/jobs/apps.py b/examples/jobs/apps.py new file mode 100644 index 00000000..c922b085 --- /dev/null +++ b/examples/jobs/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig + + +class JobsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "examples.jobs" + label = "jobs" + verbose_name = "Jobs" diff --git a/examples/jobs/migrations/0001_initial.py b/examples/jobs/migrations/0001_initial.py new file mode 100644 index 00000000..0a1846e4 --- /dev/null +++ b/examples/jobs/migrations/0001_initial.py @@ -0,0 +1,27 @@ +from django.db import migrations +from django.db import models + + +class Migration(migrations.Migration): + initial = True + dependencies: list = [] + + operations = [ + migrations.CreateModel( + name="Job", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=255)), + ("metadata", models.JSONField(blank=True, default=dict)), + ("status", models.CharField(default="idle", max_length=32)), + ], + ), + ] diff --git a/examples/jobs/migrations/__init__.py b/examples/jobs/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/examples/jobs/models.py b/examples/jobs/models.py new file mode 100644 index 00000000..7421cebc --- /dev/null +++ b/examples/jobs/models.py @@ -0,0 +1,13 @@ +from django.db import models + + +class Job(models.Model): + """A background job whose admin mixes a stock change form with a custom, + request-driven step-sequencing view — see ``admin.JobAdmin``.""" + + name = models.CharField(max_length=255) + metadata = models.JSONField(default=dict, blank=True) + status = models.CharField(max_length=32, default="idle") + + def __str__(self) -> str: + return self.name diff --git a/examples/jobs/templates/admin/jobs/job/run_custom.html b/examples/jobs/templates/admin/jobs/job/run_custom.html new file mode 100644 index 00000000..35c1bfa9 --- /dev/null +++ b/examples/jobs/templates/admin/jobs/job/run_custom.html @@ -0,0 +1,144 @@ +{% extends "admin/base_site.html" %} +{% comment %} +Custom change-view template (Path B) — a dual-listbox / ordered step picker. + +This is a *pure custom template* with a custom POST contract: no ModelForm, +no fieldsets, no model fields. The form-spec resolver detects this (the +overridden change_view renders a non-standard template) and tells the React +SPA to embed THIS legacy page in an iframe. The contract that must hold: + + request.POST.getlist("selected_steps") ← ordered, in DOM order of the + right-hand column. +{% endcomment %} + +{% block extrastyle %}{{ block.super }} + +{% endblock %} + +{% block content %} +

Drag to reorder, or use the arrows. The order on the right is +the order the steps run in.

+ +
+ {% csrf_token %} + +
+
+

Available

+
    + {% for step in available_steps %} +
  • + {{ step.label }} + +
  • + {% endfor %} +
+
+ +
+

Selected (ordered)

+
    + {% for step in selected_steps %} +
  • + {{ step.label }} + + + + + +
  • + {% endfor %} +
+
+
+ + {# Hidden inputs are (re)emitted in DOM order on submit so getlist() keeps order. #} +
+ + + + + + {% for step in all_steps %} + + {% endfor %} + +
All known steps
StepDefault order
{{ step.label }}{{ step.default_order }}
+ +
+ Cancel + +
+
+ + +{% endblock %} diff --git a/examples/jobs/tests/__init__.py b/examples/jobs/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/examples/jobs/tests/test_admin.py b/examples/jobs/tests/test_admin.py new file mode 100644 index 00000000..f00eb7d5 --- /dev/null +++ b/examples/jobs/tests/test_admin.py @@ -0,0 +1,68 @@ +"""Backend tests for the Job custom-form fixture. + +Exercises the legacy ``/admin/`` side of the contract — the side the React +SPA embeds in an iframe for Path B. Pure Django ``TestCase`` + test client; +no browser / e2e. +""" + +from django.contrib import admin +from django.contrib.auth import get_user_model +from django.test import TestCase +from django.urls import reverse + +from examples.jobs.models import Job + + +class JobAdminRegistrationTests(TestCase): + def test_job_registered(self) -> None: + self.assertIn(Job, admin.site._registry) + + +class JobCustomViewTests(TestCase): + @classmethod + def setUpTestData(cls) -> None: + cls.user = get_user_model().objects.create_superuser( + username="root", + email="root@example.com", + password="x", # noqa: S106 + ) + cls.job = Job.objects.create(name="nightly", status="idle") + + def setUp(self) -> None: + self.client.force_login(self.user) + + def _change_url(self) -> str: + return reverse("admin:jobs_job_change", args=[self.job.pk]) + + def test_path_a_renders_stock_change_form(self) -> None: + """No query → Django's stock change form (the metadata textarea).""" + resp = self.client.get(self._change_url()) + self.assertEqual(resp.status_code, 200) + self.assertContains(resp, "vLargeTextField") + self.assertNotContains(resp, 'id="run-custom-form"') + + def test_path_b_renders_custom_dual_listbox(self) -> None: + """?run_custom=1 → the hand-rolled dual-listbox template.""" + resp = self.client.get(self._change_url() + "?run_custom=1") + self.assertEqual(resp.status_code, 200) + self.assertTemplateUsed(resp, "admin/jobs/job/run_custom.html") + self.assertContains(resp, 'id="run-custom-form"') + # Defaults pre-populate the "selected" column in default_order. + self.assertContains(resp, 'data-step="fetch"') + + def test_path_b_post_preserves_order(self) -> None: + """POST keeps the right column's order via getlist().""" + resp = self.client.post( + self._change_url() + "?run_custom=1", + data={"selected_steps": ["validate", "fetch", "transform"]}, + ) + # Redirects back to the stock change page (Path A) on success. + self.assertRedirects(resp, self._change_url()) + messages = list(resp.wsgi_request._messages) + self.assertTrue(any("validate → fetch → transform" in str(m) for m in messages)) + + def test_path_b_post_empty_is_rejected(self) -> None: + resp = self.client.post(self._change_url() + "?run_custom=1", data={}) + # Redirects back to the same custom view with an error message. + self.assertEqual(resp.status_code, 302) + self.assertIn("run_custom=1", resp["Location"]) diff --git a/examples/project/settings.py b/examples/project/settings.py index 94dc09ff..5caaa030 100644 --- a/examples/project/settings.py +++ b/examples/project/settings.py @@ -43,6 +43,9 @@ "examples.blog", "examples.ecommerce", "examples.hr", + # Custom-form fixture: a ModelAdmin with a request-driven custom view + + # custom template, proving the legacy-iframe escape hatch (#659). + "examples.jobs", ] MIDDLEWARE = [ diff --git a/poetry.lock b/poetry.lock index 42601a61..e39ffde7 100644 --- a/poetry.lock +++ b/poetry.lock @@ -471,31 +471,31 @@ bcrypt = ["bcrypt"] [[package]] name = "django-admin-mcp-api" -version = "1.2.0" +version = "1.3.0" description = "MCP (Model Context Protocol) adapter for django-admin-rest-api. A wire-protocol-only layer that lets agents reach the existing REST API — no new functionality, permissions, or validation." optional = false python-versions = "<4.0,>=3.10" groups = ["main"] files = [ - {file = "django_admin_mcp_api-1.2.0-py3-none-any.whl", hash = "sha256:2f94b1597c60944c4d9bf75432354c1dfd4a2a66f8ae71da46020974ee88e28e"}, - {file = "django_admin_mcp_api-1.2.0.tar.gz", hash = "sha256:c2a82b19347ce3e2761411453ff2730a70f0e658124daa2ae504b5ffcbeb4938"}, + {file = "django_admin_mcp_api-1.3.0-py3-none-any.whl", hash = "sha256:87e51eab6733e7313e60459c6f413bc767988ea79710b84fac0ccca860d66bd8"}, + {file = "django_admin_mcp_api-1.3.0.tar.gz", hash = "sha256:4ec3dd1569b6e026710665ed9f32c1398482db8f2facf5c5955ada17de8ba2c8"}, ] [package.dependencies] django = ">=4.2,<7.0" -django-admin-rest-api = ">=1.4.0,<2.0.0" +django-admin-rest-api = ">=1.5.0,<2.0.0" jsonschema = ">=4.0,<5.0" [[package]] name = "django-admin-rest-api" -version = "1.4.0" +version = "1.5.0" description = "A JSON REST API for the Django admin — same permissions, same ModelAdmin, no new features. Powers django-admin-react and django-admin-mcp." optional = false python-versions = "<4.0,>=3.10" groups = ["main"] files = [ - {file = "django_admin_rest_api-1.4.0-py3-none-any.whl", hash = "sha256:66e9ed8536364b82e4eab139471d1115b2740233f31dacbd809e315caddd81fe"}, - {file = "django_admin_rest_api-1.4.0.tar.gz", hash = "sha256:debead8b5d0effed517e178e3fc5546c444cdd3288fae6f691999253d313f917"}, + {file = "django_admin_rest_api-1.5.0-py3-none-any.whl", hash = "sha256:3c9e6057f803a01c5d7f78144ea424f0a16206c593323c27fe6c344f6ebde310"}, + {file = "django_admin_rest_api-1.5.0.tar.gz", hash = "sha256:c559a2b04d1fbe43c773d2430c493b50e3d9467e46e23a3546e7985cc609d3fc"}, ] [package.dependencies] @@ -1900,4 +1900,4 @@ zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] [metadata] lock-version = "2.1" python-versions = "^3.10" -content-hash = "578e1141e6c1cbf842ec8e9b6a8c2ce8ce10f6376bd9b27e50a7a588c1443a1c" +content-hash = "cbedae24e804c8e2e963192f3785c7b7ef5d056911ac112467cccc78515c71d1" diff --git a/pyproject.toml b/pyproject.toml index 6c16cd06..a64a041f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "django-admin-react" -version = "1.9.0" +version = "1.10.0" description = "A drop-in React single-page admin for Django, driven entirely by ModelAdmin." authors = ["django-admin-react contributors"] license = "MIT" @@ -55,7 +55,10 @@ django = ">=4.2,<7.0" # Floor raised to 1.4.0 in v1.9.0 (#659): the change form consumes the # `form-spec` endpoint that ships in rest-api 1.4.0 (#59). The SPA degrades # gracefully on an older backend, but the documented parity needs 1.4.0+. -django-admin-rest-api = "^1.4.0" +# Raised to 1.5.0 in v1.10.0: the form-spec `legacy-iframe` fallback now +# covers request-driven custom views (a `change_view` override that renders +# a non-standard template), which the `examples/jobs` fixture exercises. +django-admin-rest-api = "^1.5.0" # `django-admin-mcp-api` — MCP-protocol adapter over the same REST API # so agents reach the SAME `ModelAdmin`-driven surface. Wire-protocol-only # layer; adds NO new functionality, permissions, or validation. @@ -64,7 +67,7 @@ django-admin-rest-api = "^1.4.0" # so a consumer on Django 4.2 actually gets a Django-4.2-compatible MCP # (the 1.0.x line pins ``django>=5.0``, which would refuse to install # alongside Django 4.2). -django-admin-mcp-api = ">=1.2.0,<2.0.0" +django-admin-mcp-api = ">=1.3.0,<2.0.0" [tool.poetry.group.dev.dependencies] # Dependabot #5: pytest's tmpdir handled symlinks unsafely