Skip to content

Commit b7aae8d

Browse files
MartinCastroAlvarezmartin-castro-laminr-aiclaude
authored
fix(api): get_form(change=True) for existing objects — detail/update/bulk (#186)
The detail, update (PATCH), and bulk-PATCH views all build the form via `model_admin.get_form(request, obj=obj)` WITHOUT `change=True`. Django's own change view always passes `change=not add` (i.e. True for an existing object — see `ModelAdmin._changeform_view`). A consumer `get_form` override commonly branches on `change` to return a change-specific form whose `Meta` omits a *form-only* field (one that isn't a model field — e.g. an `admin_override` toggle rendered by a custom form). Called without `change=True`, that override falls through to `modelform_factory`, which raises `FieldError: Unknown field(s) (admin_override) ...` → 500. Observed in the laminr pilot: `GET /admin2/api/v1/package_reviews/ underreviewstatus/<pk>/` 500'd because `UnderReviewStatusAdmin.get_form` returns its safe `UnderReviewStatusForm` only when `change=True`, else defers to the default factory which chokes on the form-only `admin_override` field. Fix: pass `change=True` at all three existing-object call sites (detail.py, update.py, bulk.py). create.py is unchanged — it correctly calls `get_form(obj=None)` with the default `change=False` for the add path. Regression test (test_detail.py::test_detail_calls_get_form_with_change_true): a get_form that raises unless `change=True` for an existing object now yields a 200; asserts `change=True` was the value seen. 41/41 detail+update+bulk tests pass. Co-authored-by: Martin Castro Laminrs <mcastro@laminr.ai> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d182778 commit b7aae8d

4 files changed

Lines changed: 61 additions & 3 deletions

File tree

django_admin_react/api/views/bulk.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,8 @@ def _apply_one(
191191

192192
return {"pk": pk, "ok": False, "error": _json.loads(body)["error"]}
193193

194-
form = model_admin.get_form(request, obj=obj)(
194+
# change=True — bulk PATCH targets existing rows (see detail.py).
195+
form = model_admin.get_form(request, obj=obj, change=True)(
195196
data=merged_initial_for_update(obj, writable, fields, model),
196197
files=None,
197198
instance=obj,

django_admin_react/api/views/detail.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,16 @@ def _fields_payload(
193193
) -> dict[str, dict[str, Any]]:
194194
"""Build the per-field descriptor mapping (contract §4 ``fields``)."""
195195
readonly = set(model_admin.get_readonly_fields(request, obj) or ())
196-
form = model_admin.get_form(request, obj=obj)(instance=obj)
196+
# ``change=True`` — the detail view is always for an EXISTING object,
197+
# so we mirror exactly how Django's change view calls ``get_form``
198+
# (``ModelAdmin._changeform_view`` passes ``change=not add``). A
199+
# consumer ``get_form`` override commonly branches on ``change`` to
200+
# return a change-specific form (one whose Meta omits form-only
201+
# fields like ``admin_override``). Calling without ``change=True``
202+
# makes that override fall through to the default factory, which
203+
# then raises ``FieldError`` on the form-only field and 500s the
204+
# detail endpoint.
205+
form = model_admin.get_form(request, obj=obj, change=True)(instance=obj)
197206

198207
out: dict[str, dict[str, Any]] = {}
199208
for name in visible_names:

django_admin_react/api/views/update.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,11 @@ def patch(
124124
if rejection is not None:
125125
return rejection
126126

127-
form = model_admin.get_form(request, obj=obj)(
127+
# change=True — PATCH targets an existing object, so mirror
128+
# Django's change view (see detail.py for the rationale; a
129+
# consumer get_form override that branches on `change` must hit
130+
# its change-form path, not the default factory).
131+
form = model_admin.get_form(request, obj=obj, change=True)(
128132
data=merged_initial_for_update(obj, writable, payload, model),
129133
files=None,
130134
instance=obj,

tests/test_detail.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,50 @@ def _url(pk: object) -> str:
1818
return f"/admin-react/api/v1/auth/group/{pk}/"
1919

2020

21+
@pytest.mark.django_db
22+
def test_detail_calls_get_form_with_change_true(superuser_client: Client) -> None:
23+
"""Regression: the detail view must call ``get_form(..., change=True)``
24+
for an existing object — exactly how Django's change view invokes it.
25+
26+
A consumer ``get_form`` commonly branches on ``change`` and returns a
27+
change-specific form whose ``Meta`` omits a *form-only* field (one
28+
that isn't a model field, e.g. an ``admin_override`` toggle). If the
29+
detail view calls ``get_form`` WITHOUT ``change=True``, that override
30+
falls through to ``modelform_factory`` on the form-only field and
31+
raises ``FieldError`` → 500. Observed in the laminr pilot on
32+
``package_reviews.UnderReviewStatus``.
33+
"""
34+
from django import forms
35+
36+
g = Group.objects.create(name="example")
37+
seen: dict[str, object] = {}
38+
ok_form = forms.modelform_factory(Group, fields=["name"])
39+
40+
def branching_get_form(self, request, obj=None, change=False, **kwargs): # noqa: ANN001
41+
seen["change"] = change
42+
if obj is not None and change:
43+
return ok_form
44+
# Mirror the consumer's failure mode: the non-change path would
45+
# blow up building a form for a form-only field. Raise so the
46+
# test fails loudly if the detail view didn't pass change=True.
47+
raise AssertionError("get_form must be called with change=True for an existing object")
48+
49+
# Pin `get_fields` so Django's own get_fields → get_form(change=False)
50+
# path is bypassed (laminr's admin sets `fields` via its @sections
51+
# decorator, so only our explicit _fields_payload get_form call
52+
# fires). This isolates the call path the fix targets.
53+
with admin_override(
54+
Group,
55+
get_fields=lambda self, request, obj=None: ["name"],
56+
get_fieldsets=lambda self, request, obj=None: [(None, {"fields": ["name"]})],
57+
get_form=branching_get_form,
58+
):
59+
response = superuser_client.get(_url(g.pk))
60+
61+
assert response.status_code == 200, response.content
62+
assert seen.get("change") is True
63+
64+
2165
# --------------------------------------------------------------------------- #
2266
# Mandatory 8-row matrix #
2367
# --------------------------------------------------------------------------- #

0 commit comments

Comments
 (0)