django-admin-react 0.2.0a5__tar.gz → 0.2.0a6__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a6}/PKG-INFO +1 -1
  2. {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a6}/django_admin_react/api/filters.py +29 -4
  3. {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a6}/django_admin_react/api/inlines.py +28 -0
  4. {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a6}/django_admin_react/api/inlines_write.py +15 -0
  5. {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a6}/django_admin_react/api/views/bulk.py +37 -4
  6. django_admin_react-0.2.0a6/django_admin_react/api/views/create.py +213 -0
  7. {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a6}/django_admin_react/api/views/create_form.py +37 -0
  8. {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a6}/django_admin_react/api/views/detail.py +20 -8
  9. {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a6}/django_admin_react/api/views/list.py +65 -2
  10. {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a6}/django_admin_react/api/views/password.py +5 -1
  11. {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a6}/django_admin_react/api/views/schema.py +20 -0
  12. {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a6}/django_admin_react/api/views/update.py +80 -31
  13. {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a6}/django_admin_react/api/writes.py +27 -0
  14. {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a6}/django_admin_react/conf.py +9 -0
  15. {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a6}/django_admin_react/static/admin_react/.vite/manifest.json +2 -2
  16. django_admin_react-0.2.0a6/django_admin_react/static/admin_react/assets/index-Beowap5h.css +1 -0
  17. django_admin_react-0.2.0a6/django_admin_react/static/admin_react/assets/index-CgWOpEY8.js +8 -0
  18. {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a6}/django_admin_react/static/admin_react/index.html +2 -2
  19. {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a6}/django_admin_react/templates/admin_react/index.html +8 -0
  20. {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a6}/django_admin_react/views.py +23 -0
  21. {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a6}/pyproject.toml +1 -1
  22. django_admin_react-0.2.0a5/django_admin_react/api/views/create.py +0 -143
  23. django_admin_react-0.2.0a5/django_admin_react/static/admin_react/assets/index-BncyUUo8.js +0 -8
  24. django_admin_react-0.2.0a5/django_admin_react/static/admin_react/assets/index-Bt-X3hQW.css +0 -1
  25. {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a6}/LICENSE +0 -0
  26. {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a6}/README.md +0 -0
  27. {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a6}/django_admin_react/README.md +0 -0
  28. {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a6}/django_admin_react/__init__.py +0 -0
  29. {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a6}/django_admin_react/api/README.md +0 -0
  30. {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a6}/django_admin_react/api/__init__.py +0 -0
  31. {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a6}/django_admin_react/api/dates.py +0 -0
  32. {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a6}/django_admin_react/api/panels.py +0 -0
  33. {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a6}/django_admin_react/api/permissions.py +0 -0
  34. {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a6}/django_admin_react/api/registry.py +0 -0
  35. {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a6}/django_admin_react/api/serializers.py +0 -0
  36. {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a6}/django_admin_react/api/urls.py +0 -0
  37. {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a6}/django_admin_react/api/views/README.md +0 -0
  38. {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a6}/django_admin_react/api/views/__init__.py +0 -0
  39. {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a6}/django_admin_react/api/views/actions.py +0 -0
  40. {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a6}/django_admin_react/api/views/auth.py +0 -0
  41. {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a6}/django_admin_react/api/views/autocomplete.py +0 -0
  42. {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a6}/django_admin_react/api/views/delete_preview.py +0 -0
  43. {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a6}/django_admin_react/api/views/destroy.py +0 -0
  44. {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a6}/django_admin_react/api/views/history.py +0 -0
  45. {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a6}/django_admin_react/api/views/registry.py +0 -0
  46. {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a6}/django_admin_react/apps.py +0 -0
  47. {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a6}/django_admin_react/audit.py +0 -0
  48. {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a6}/django_admin_react/pwa.py +0 -0
  49. {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a6}/django_admin_react/templates/README.md +0 -0
  50. {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a6}/django_admin_react/templates/admin_react/README.md +0 -0
  51. {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a6}/django_admin_react/templates/admin_react/login.html +0 -0
  52. {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a6}/django_admin_react/templates/admin_react/sw.js +0 -0
  53. {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a6}/django_admin_react/urls.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: django-admin-react
3
- Version: 0.2.0a5
3
+ Version: 0.2.0a6
4
4
  Summary: A drop-in React single-page admin for Django, driven entirely by ModelAdmin.
5
5
  License: MIT
6
6
  Keywords: django,admin,react,spa,tailwind
@@ -144,17 +144,30 @@ def _spec_for_fk(
144
144
  "to": {"app_label": meta.app_label, "model_name": meta.model_name},
145
145
  }
146
146
  # Inline up to _FK_FILTER_MAX_OPTIONS choices for tiny tables;
147
- # larger tables defer to the autocomplete endpoint (#59).
147
+ # larger tables defer to the autocomplete endpoint (#59). Respect the
148
+ # FK's ``limit_choices_to`` so the offered options match Django's
149
+ # RelatedFieldListFilter, whose choices come from
150
+ # ``complex_filter(limit_choices_to)`` — a FK declared with, e.g.,
151
+ # ``limit_choices_to={"is_active": True}`` must not offer the rows it
152
+ # excludes (#273). An unset / empty / callable-returning-empty limit
153
+ # is falsy, so the unfiltered manager is used unchanged (and we never
154
+ # call ``complex_filter(None)``, which would raise).
155
+ base_qs = related._default_manager.all()
156
+ limit = field.get_limit_choices_to()
157
+ if limit:
158
+ try:
159
+ base_qs = related._default_manager.complex_filter(limit)
160
+ except Exception:
161
+ base_qs = related._default_manager.all()
148
162
  try:
149
- count = related._default_manager.count()
163
+ count = base_qs.count()
150
164
  except Exception:
151
165
  count = _FK_FILTER_MAX_OPTIONS + 1
152
166
  if count <= _FK_FILTER_MAX_OPTIONS:
153
167
  from django_admin_react.api.serializers import label_for
154
168
 
155
169
  payload["choices"] = [
156
- {"value": obj.pk, "label": label_for(obj)}
157
- for obj in related._default_manager.all()[:_FK_FILTER_MAX_OPTIONS]
170
+ {"value": obj.pk, "label": label_for(obj)} for obj in base_qs[:_FK_FILTER_MAX_OPTIONS]
158
171
  ]
159
172
  return payload
160
173
 
@@ -179,10 +192,22 @@ def _spec_for_simple_filter(
179
192
  lookups = list(instance.lookups(request, model_admin) or [])
180
193
  except Exception: # pragma: no cover — admin author error
181
194
  lookups = []
195
+ # The lookup the filter is currently applying — Django's
196
+ # ``SimpleListFilter.value()``. Crucially this includes a *default*
197
+ # the filter applies when no querystring param is present (a common
198
+ # "exclude test tenants unless opted in" pattern): such a filter
199
+ # returns its default from ``value()``, so the SPA can reflect the
200
+ # default as selected instead of showing "All" while the backend
201
+ # silently narrows the rows (#283). ``None`` means no selection.
202
+ try:
203
+ selected = instance.value()
204
+ except Exception: # pragma: no cover — admin author error
205
+ selected = None
182
206
  return {
183
207
  "name": instance.parameter_name,
184
208
  "label": str(getattr(instance, "title", "") or instance.parameter_name),
185
209
  "type": "custom",
210
+ "selected": selected,
186
211
  "lookups": [{"value": v, "label": str(lbl)} for v, lbl in lookups],
187
212
  }
188
213
 
@@ -100,6 +100,26 @@ def _get_inline_instances(
100
100
  return []
101
101
 
102
102
 
103
+ def _show_change_link_allowed(
104
+ admin_site: Any, child_model: type[Model], request: HttpRequest
105
+ ) -> bool:
106
+ """Whether to advertise an inline row's link to the child's change page.
107
+
108
+ Mirrors ``serialize_fk_value``'s ``to`` gate (#301): only when the child
109
+ model is registered on this admin site **and** the requesting user has
110
+ ``has_view_permission`` for it. Registration alone isn't enough — without
111
+ the per-user check the SPA would render a link the detail endpoint 404s
112
+ on and leak the adjacency / identity of a model the user can't view
113
+ (extends the #89 registry guard to a per-user check).
114
+ """
115
+ if admin_site is None:
116
+ return False
117
+ target_admin = getattr(admin_site, "_registry", {}).get(child_model)
118
+ if target_admin is None:
119
+ return False
120
+ return bool(target_admin.has_view_permission(request))
121
+
122
+
103
123
  def _spec_for_inline(
104
124
  inline: InlineModelAdmin,
105
125
  parent: Model,
@@ -148,6 +168,14 @@ def _spec_for_inline(
148
168
  "can_add": can_add,
149
169
  "can_change": can_change,
150
170
  "can_delete": can_delete,
171
+ # InlineModelAdmin.show_change_link (#384) — when True, the SPA
172
+ # renders a per-row link to the child's own change page. Gated on
173
+ # the child being registered **and** the user's per-model
174
+ # has_view_permission (#301 least-disclosure, same gate as
175
+ # serialize_fk_value's `to`): never advertise a link the detail
176
+ # endpoint would 404 on, never leak adjacency to an unviewable model.
177
+ "show_change_link": bool(getattr(inline, "show_change_link", False))
178
+ and _show_change_link_allowed(admin_site, child_model, request),
151
179
  "fields": fields_meta,
152
180
  "rows": rows,
153
181
  }
@@ -69,6 +69,21 @@ class InlinePermissionDenied(Exception):
69
69
  self.state = state
70
70
 
71
71
 
72
+ class InlineValidationError(Exception):
73
+ """Carries inline formset errors out of the caller's ``atomic()`` block.
74
+
75
+ Raised so the transaction unwinds (reverting the parent write), then
76
+ caught immediately outside the block and converted to a 400 with the
77
+ per-inline error detail. Using an exception rather than an early return
78
+ is what guarantees the rollback — a plain return inside ``atomic()``
79
+ would commit the parent. Shared by the create + update endpoints.
80
+ """
81
+
82
+ def __init__(self, errors: dict) -> None:
83
+ super().__init__("inline formset validation failed")
84
+ self.errors = errors
85
+
86
+
72
87
  def _inline_name(inline: InlineModelAdmin, parent: Model) -> str:
73
88
  """The identifier the read half emits for this inline.
74
89
 
@@ -33,6 +33,7 @@ from __future__ import annotations
33
33
 
34
34
  from typing import Any
35
35
 
36
+ from django.db import IntegrityError
36
37
  from django.db import transaction
37
38
  from django.http import HttpRequest
38
39
  from django.http import HttpResponse
@@ -44,6 +45,7 @@ from django_admin_react.api.permissions import is_admin_user
44
45
  from django_admin_react.api.registry import get_admin_site
45
46
  from django_admin_react.api.registry import resolve_model
46
47
  from django_admin_react.api.writes import bad_request
48
+ from django_admin_react.api.writes import conflict_error
47
49
  from django_admin_react.api.writes import form_errors_to_envelope
48
50
  from django_admin_react.api.writes import load_object_or_none
49
51
  from django_admin_react.api.writes import log_change
@@ -180,6 +182,26 @@ def _apply_one(
180
182
  "error": {"code": "forbidden", "message": "You do not have permission."},
181
183
  }
182
184
 
185
+ # list_editable parity + scope guard (#401): this endpoint powers the
186
+ # changelist's inline-editable cells, so a write may only touch fields
187
+ # the admin put in ``list_editable`` — exactly like Django, which only
188
+ # accepts ``list_editable`` names on a changelist POST. A field that's
189
+ # writable on the *change form* but not list_editable (or ANY field
190
+ # when list_editable is empty) is rejected here, even though the user
191
+ # could edit it through the detail form. This keeps the bulk surface
192
+ # from silently widening the set of fields editable from the list.
193
+ list_editable = set(getattr(model_admin, "list_editable", ()) or ())
194
+ not_editable = sorted(k for k in fields if k not in list_editable)
195
+ if not_editable:
196
+ return {
197
+ "pk": pk,
198
+ "ok": False,
199
+ "error": {
200
+ "code": "bad_request",
201
+ "message": f"Field(s) not editable in the list view: {', '.join(not_editable)}.",
202
+ },
203
+ }
204
+
183
205
  writable = writable_field_names(model, model_admin, request, obj)
184
206
  forbidden = readonly_or_excluded_names(model_admin, request, obj)
185
207
  rejection = reject_forbidden_keys(fields, writable, forbidden)
@@ -208,8 +230,19 @@ def _apply_one(
208
230
  },
209
231
  }
210
232
 
211
- instance = form.save(commit=False)
212
- model_admin.save_model(request, instance, form, change=True)
213
- form.save_m2m()
214
- log_change(model_admin, request, instance, form)
233
+ # Per-row savepoint so a DB IntegrityError the form didn't catch (a
234
+ # uniqueness race, or a DB-level constraint) rolls back just this row
235
+ # and returns a clean per-row error — keeping the surrounding batch
236
+ # transaction usable instead of aborting it (and 500ing) (#404). The
237
+ # batch still rolls everything back when any row is rejected.
238
+ try:
239
+ with transaction.atomic():
240
+ instance = form.save(commit=False)
241
+ model_admin.save_model(request, instance, form, change=True)
242
+ # M2M / related via the admin hook (#402) so a consumer's
243
+ # save_related override runs (default = save_m2m).
244
+ model_admin.save_related(request, form, [], change=True)
245
+ log_change(model_admin, request, instance, form)
246
+ except IntegrityError:
247
+ return {"pk": pk, "ok": False, "error": conflict_error()}
215
248
  return {"pk": pk, "ok": True}
@@ -0,0 +1,213 @@
1
+ """``POST /api/v1/<app>/<model>/`` — create endpoint.
2
+
3
+ Wire contract: ``docs/api-contract.md`` §5.1.
4
+
5
+ Hard rules (`SECURITY.md` §3, `ACCEPTANCE.md` §3.1):
6
+
7
+ - Rule 1: Staff + ``AdminSite.has_permission`` gate.
8
+ - Rule 3: Model resolved through ``admin.site._registry`` (B-7).
9
+ - Rule 6: Writes go through ``ModelAdmin.get_form()`` then
10
+ ``save_model(..., change=False)`` (B-3).
11
+ - Rule 12: Unknown / readonly / excluded / sensitive payload keys → 400,
12
+ never a silent drop.
13
+ - CSRF: No ``@csrf_exempt`` — Django's middleware enforces.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from typing import Any
19
+
20
+ from django.core.exceptions import RequestDataTooBig
21
+ from django.core.exceptions import TooManyFieldsSent
22
+ from django.db import IntegrityError
23
+ from django.db import transaction
24
+ from django.http import HttpRequest
25
+ from django.http import HttpResponse
26
+ from django.http import JsonResponse
27
+ from django.views.generic import View
28
+
29
+ from django_admin_react.api.inlines_write import InlinePermissionDenied
30
+ from django_admin_react.api.inlines_write import InlineValidationError
31
+ from django_admin_react.api.inlines_write import apply_inline_writes
32
+ from django_admin_react.api.permissions import forbidden_response
33
+ from django_admin_react.api.permissions import is_admin_user
34
+ from django_admin_react.api.registry import get_admin_site
35
+ from django_admin_react.api.registry import resolve_model
36
+ from django_admin_react.api.serializers import label_for
37
+ from django_admin_react.api.writes import bad_request
38
+ from django_admin_react.api.writes import coerce_fk_values
39
+ from django_admin_react.api.writes import conflict_response
40
+ from django_admin_react.api.writes import form_errors_to_envelope
41
+ from django_admin_react.api.writes import log_addition
42
+ from django_admin_react.api.writes import not_found_response
43
+ from django_admin_react.api.writes import parse_json_body
44
+ from django_admin_react.api.writes import readonly_or_excluded_names
45
+ from django_admin_react.api.writes import reject_forbidden_keys
46
+ from django_admin_react.api.writes import validation_failed
47
+ from django_admin_react.api.writes import writable_field_names
48
+
49
+
50
+ class CreateView(View):
51
+ """``POST /api/v1/<app_label>/<model_name>/``."""
52
+
53
+ http_method_names = ["post"]
54
+
55
+ def post(
56
+ self,
57
+ request: HttpRequest,
58
+ app_label: str,
59
+ model_name: str,
60
+ *args: Any,
61
+ **kwargs: Any,
62
+ ) -> HttpResponse:
63
+ """Create a new instance (contract §5.1).
64
+
65
+ Gates: ``is_admin_user`` → ``resolve_model`` →
66
+ ``has_add_permission(request)``. CSRF enforcement is Django's
67
+ ``CsrfViewMiddleware`` — no ``@csrf_exempt`` (rule 4 /
68
+ ACCEPTANCE §4.6 S-26).
69
+
70
+ Payload validation runs **before** the form is built:
71
+
72
+ - Unknown keys → 400 ``bad_request``.
73
+ - Keys matching ``get_readonly_fields`` or ``get_exclude`` →
74
+ 400 (rule 12 / S-22, S-23).
75
+ - Keys matching the sensitive-name denylist → 400 (S-31).
76
+
77
+ The actual write goes through ``ModelAdmin.get_form()`` →
78
+ ``form.save(commit=False)`` → ``model_admin.save_model(...)``
79
+ — never ``setattr`` (rule 6 / B-3). Wrapped in
80
+ ``transaction.atomic()``.
81
+ """
82
+ admin_site = get_admin_site()
83
+ if not is_admin_user(request, admin_site=admin_site):
84
+ return forbidden_response(request)
85
+
86
+ resolved = resolve_model(admin_site, request, app_label, model_name)
87
+ if resolved is None:
88
+ return not_found_response()
89
+ model, model_admin = resolved
90
+
91
+ if not model_admin.has_add_permission(request):
92
+ return forbidden_response(request)
93
+
94
+ # FileField / ImageField uploads arrive as multipart/form-data
95
+ # (#241). Branch on content type: multipart feeds the ModelForm
96
+ # ``request.POST`` (a QueryDict, so ``getlist`` preserves M2M) +
97
+ # ``request.FILES``; JSON keeps the existing envelope path. CSRF is
98
+ # enforced either way — ``CsrfViewMiddleware`` ran before this view
99
+ # and there is no ``@csrf_exempt``.
100
+ # Optional inline formsets (#403) — set only on the JSON path; a
101
+ # multipart create (file uploads) doesn't carry inlines.
102
+ inlines_payload: Any = None
103
+ is_multipart = (request.content_type or "").startswith("multipart/form-data")
104
+ if is_multipart:
105
+ form_data: Any
106
+ files: Any
107
+ # Accessing request.POST/FILES triggers the multipart parse, which
108
+ # enforces Django's body limits. Surface an over-limit upload as
109
+ # the canonical JSON envelope instead of Django's default 400
110
+ # page (#448) — RequestDataTooBig / TooManyFieldsSent are
111
+ # SuspiciousOperation subclasses, not MultiPartParserError.
112
+ try:
113
+ form_data = request.POST
114
+ files = request.FILES
115
+ except (RequestDataTooBig, TooManyFieldsSent):
116
+ return bad_request("Upload exceeds the configured size or field limits.")
117
+ # Validate the union of POST + FILES keys: a file posted to a
118
+ # readonly / excluded / unknown field is rejected just like a
119
+ # scalar would be. Bare multipart values are not {id,label}
120
+ # envelopes, so ``coerce_fk_values`` is skipped.
121
+ submitted_keys: dict[str, Any] = dict.fromkeys(form_data)
122
+ submitted_keys.update(dict.fromkeys(files))
123
+ else:
124
+ parsed = parse_json_body(request)
125
+ if isinstance(parsed, HttpResponse):
126
+ return parsed
127
+ payload: dict[str, Any] = parsed
128
+ # Strip the inline block before validating parent keys so it
129
+ # isn't treated as an unknown field; it's saved after the parent.
130
+ inlines_payload = payload.pop("inlines", None)
131
+ form_data = coerce_fk_values(payload, model)
132
+ files = None
133
+ submitted_keys = payload
134
+
135
+ writable = writable_field_names(model, model_admin, request, obj=None)
136
+ forbidden = readonly_or_excluded_names(model_admin, request, obj=None)
137
+ rejection = reject_forbidden_keys(submitted_keys, writable, forbidden)
138
+ if rejection is not None:
139
+ return rejection
140
+
141
+ form = model_admin.get_form(request, obj=None)(data=form_data, files=files)
142
+ if not form.is_valid():
143
+ return validation_failed(form_errors_to_envelope(form))
144
+
145
+ # A DB IntegrityError the form didn't catch (a uniqueness race, or a
146
+ # DB-level constraint not mirrored in form validation) must exit the
147
+ # atomic block before it's handled — catch outside (#404).
148
+ try:
149
+ with transaction.atomic():
150
+ instance = form.save(commit=False)
151
+ model_admin.save_model(request, instance, form, change=False)
152
+ # Save M2M / related through the admin hook (#402), not a
153
+ # bare form.save_m2m(), so a consumer's save_related override
154
+ # is honoured. The default save_related just runs save_m2m;
155
+ # inline formsets flow through our own write path, so the
156
+ # `formsets` list is empty here.
157
+ model_admin.save_related(request, form, [], change=False)
158
+ log_addition(model_admin, request, instance, form)
159
+ # Inline formsets (#403) round-trip in the SAME transaction
160
+ # as the parent create, so a child permission denial or a
161
+ # formset validation failure reverts the parent too — exactly
162
+ # how the update endpoint handles them.
163
+ if inlines_payload is not None:
164
+ inline_errors = apply_inline_writes(
165
+ model_admin, request, instance, form, inlines_payload
166
+ )
167
+ if inline_errors is not None:
168
+ raise InlineValidationError(inline_errors)
169
+ except InlinePermissionDenied:
170
+ return forbidden_response(request)
171
+ except InlineValidationError as exc:
172
+ return validation_failed({"inlines": exc.errors})
173
+ except IntegrityError:
174
+ return conflict_response()
175
+ except ValueError:
176
+ # Malformed `inlines` payload shape (not a 500) — fixed generic
177
+ # message, never echoing exception text (CodeQL stack-trace).
178
+ return bad_request("Malformed 'inlines' payload.")
179
+
180
+ body = {
181
+ "pk": instance.pk,
182
+ "label": label_for(instance),
183
+ "redirect": _redirect_for(
184
+ request,
185
+ model._meta.app_label,
186
+ model._meta.model_name or "",
187
+ instance.pk,
188
+ ),
189
+ }
190
+ response = JsonResponse(body, status=201)
191
+ response["Cache-Control"] = "no-store"
192
+ return response
193
+
194
+
195
+ def _redirect_for(
196
+ request: HttpRequest,
197
+ app_label: str,
198
+ model_name: str,
199
+ pk: Any,
200
+ ) -> str:
201
+ """Construct a SPA-relative redirect (``<mount>/<app>/<model>/<pk>/``).
202
+
203
+ The mount is reconstructed from the request path. The URL pattern
204
+ is fixed inside this package, so everything in front of
205
+ ``api/v1/`` is the consumer-chosen prefix
206
+ (``ARCHITECTURE.md`` §4.5). Falls back to ``/`` if the pattern is
207
+ not present (should not happen — the URL router routed us here).
208
+ """
209
+ suffix = "api/v1/"
210
+ path = request.path
211
+ idx = path.rfind(suffix)
212
+ mount = path[:idx] if idx != -1 else "/"
213
+ return f"{mount}{app_label}/{model_name}/{pk}/"
@@ -96,7 +96,44 @@ class AddFormView(View):
96
96
  # Add-view save-flow buttons (#154): obj=None → add semantics
97
97
  # (Save / Save-and-add-another / Save-and-continue editing).
98
98
  "save_options": save_options(model_admin, request, None),
99
+ # prepopulated_fields (#245): {target: [sources]} so the SPA can
100
+ # slugify the target from its sources while typing — Django's
101
+ # add-form behaviour. Restrict to fields actually rendered, and
102
+ # never a readonly target (it can't be filled), mirroring how
103
+ # Django drops readonly targets from the change-form JS.
104
+ "prepopulated_fields": _prepopulated_payload(
105
+ model_admin, request, visible_names, readonly
106
+ ),
99
107
  }
100
108
  response = JsonResponse(payload, status=200)
101
109
  response["Cache-Control"] = "no-store"
102
110
  return response
111
+
112
+
113
+ def _prepopulated_payload(
114
+ model_admin: Any,
115
+ request: HttpRequest,
116
+ visible_names: list[str],
117
+ readonly: set[str],
118
+ ) -> dict[str, list[str]]:
119
+ """Build the ``prepopulated_fields`` block (#245).
120
+
121
+ Returns ``{target: [sources]}`` from ``ModelAdmin.prepopulated_fields``,
122
+ restricted to fields actually rendered: a target that's readonly or not
123
+ in the form is dropped (it can't be filled), and source names the form
124
+ doesn't render are filtered out. A target left with no usable sources is
125
+ omitted. The SPA slugifies the target from its sources while typing.
126
+ """
127
+ try:
128
+ raw = model_admin.get_prepopulated_fields(request, None) or {}
129
+ except Exception: # pragma: no cover — admin author error
130
+ return {}
131
+ visible = set(visible_names)
132
+ out: dict[str, list[str]] = {}
133
+ for target, sources in raw.items():
134
+ if target not in visible or target in readonly:
135
+ continue
136
+ kept = [s for s in sources if s in visible]
137
+ if kept:
138
+ out[target] = kept
139
+ return out
@@ -207,17 +207,26 @@ def _fieldsets_payload(
207
207
  except Exception:
208
208
  raw = ()
209
209
  if not raw:
210
- return [{"title": None, "fields": visible_names}]
210
+ return [{"title": None, "fields": visible_names, "field_rows": [[n] for n in visible_names]}]
211
211
 
212
212
  visible_set = set(visible_names)
213
213
  payload: list[dict[str, Any]] = []
214
214
  for title, opts in raw:
215
- fields = [
216
- sub
217
- for entry in opts.get("fields", ())
218
- for sub in (entry if isinstance(entry, list | tuple) else (entry,))
219
- if sub in visible_set
220
- ]
215
+ # Preserve Django's multi-field-row grouping (#382): a fieldset
216
+ # ``fields`` entry that is a tuple/list — e.g. ``(("first", "last"),
217
+ # "email")`` is one display row. ``field_rows`` keeps that shape
218
+ # (each inner list = one row, after the visibility filter); the flat
219
+ # ``fields`` is kept for back-compat as the row-flattened list.
220
+ field_rows: list[list[str]] = []
221
+ for entry in opts.get("fields", ()):
222
+ row = [
223
+ sub
224
+ for sub in (entry if isinstance(entry, list | tuple) else (entry,))
225
+ if sub in visible_set
226
+ ]
227
+ if row:
228
+ field_rows.append(row)
229
+ fields = [sub for row in field_rows for sub in row]
221
230
  if fields:
222
231
  # Carry the fieldset's ``classes`` (e.g. ``collapse`` / ``wide``)
223
232
  # and ``description`` so the SPA can render a collapsible section
@@ -228,11 +237,14 @@ def _fieldsets_payload(
228
237
  {
229
238
  "title": title,
230
239
  "fields": fields,
240
+ "field_rows": field_rows,
231
241
  "classes": classes,
232
242
  "description": str(description) if description else None,
233
243
  }
234
244
  )
235
- return payload or [{"title": None, "fields": visible_names}]
245
+ return payload or [
246
+ {"title": None, "fields": visible_names, "field_rows": [[n] for n in visible_names]}
247
+ ]
236
248
 
237
249
 
238
250
  def _fields_payload(
@@ -46,6 +46,19 @@ from django_admin_react.api.serializers import serialize_value
46
46
  from django_admin_react.api.views.actions import actions_payload
47
47
  from django_admin_react.api.writes import not_found_response
48
48
 
49
+ # Query params the list view manages itself (pagination / sort / search);
50
+ # any *other* key is a list_filter or date_hierarchy lookup, i.e. the list
51
+ # is narrowed. Used to decide whether the unfiltered ``full_count`` could
52
+ # differ from ``total`` (#311) — and so whether the extra COUNT(*) is worth
53
+ # running at all. ``all`` is Django's ``ALL_VAR`` "Show all" flag, not a
54
+ # filter lookup, so it must not flip the list into the narrowed branch.
55
+ _COUNT_RESERVED_PARAMS = frozenset({"page", "page_size", "ordering", "q", "all"})
56
+
57
+ # Django's ``ALL_VAR`` — the query param its changelist uses for the
58
+ # "Show all N" link (#385). Its mere presence requests show-all; the value
59
+ # is ignored, mirroring Django.
60
+ _ALL_VAR = "all"
61
+
49
62
 
50
63
  class ListView(View):
51
64
  """``GET /api/v1/<app_label>/<model_name>/`` — paginated list."""
@@ -90,6 +103,9 @@ class ListView(View):
90
103
  # Apply ``list_select_related`` up front so FK columns don't issue
91
104
  # one query per row (Django changelist parity / N+1 fix).
92
105
  queryset = _apply_select_related(queryset, model_admin, list_display)
106
+ # The admin's unfiltered base — captured before search / list_filter
107
+ # / date narrowing — for the ``show_full_result_count`` parity below.
108
+ base_queryset = queryset
93
109
 
94
110
  q = request.GET.get("q", "") or ""
95
111
  if q and model_admin.search_fields:
@@ -108,8 +124,40 @@ class ListView(View):
108
124
 
109
125
  total = queryset.count()
110
126
 
111
- page_size = _clamp_page_size(request.GET.get("page_size"))
112
- page = _clamp_page(request.GET.get("page"))
127
+ # ``show_full_result_count`` parity (#311): when the list is
128
+ # narrowed (search / list_filter / date_hierarchy), surface the
129
+ # unfiltered base count so the SPA can show "X of Y". Honour
130
+ # ``ModelAdmin.show_full_result_count`` (default True) — Django's
131
+ # opt-out for tables where the extra COUNT(*) is too expensive,
132
+ # in which case we send ``null``. When the view isn't narrowed the
133
+ # full count equals ``total``, so skip the redundant query.
134
+ show_full = getattr(model_admin, "show_full_result_count", True)
135
+ narrowed = bool(q) or any(k not in _COUNT_RESERVED_PARAMS for k in request.GET)
136
+ if not show_full:
137
+ full_count: int | None = None
138
+ elif narrowed:
139
+ full_count = base_queryset.count()
140
+ else:
141
+ full_count = total
142
+
143
+ # "Show all N" parity (#385): Django's changelist drops pagination
144
+ # when the ``all`` param (its ``ALL_VAR``) is present AND the result
145
+ # count is at/below ``list_max_show_all`` (default 200). Above that
146
+ # cap the flag is ignored and the list paginates normally — the
147
+ # guard that stops a crawler forcing a huge unbounded materialise.
148
+ list_max_show_all = int(getattr(model_admin, "list_max_show_all", 200))
149
+ show_all = _ALL_VAR in request.GET and total <= list_max_show_all
150
+
151
+ if show_all:
152
+ # One page holding every row: page 1, page_size = total. The
153
+ # ``max(total, 1)`` keeps page_size positive for an empty list
154
+ # (a 0-size page would slice to nothing, but there's nothing to
155
+ # show anyway).
156
+ page = 1
157
+ page_size = max(total, 1)
158
+ else:
159
+ page_size = _clamp_page_size(request.GET.get("page_size"))
160
+ page = _clamp_page(request.GET.get("page"))
113
161
  start = (page - 1) * page_size
114
162
  end = start + page_size
115
163
 
@@ -134,6 +182,13 @@ class ListView(View):
134
182
  "object_name": model._meta.object_name,
135
183
  "verbose_name": str(model._meta.verbose_name),
136
184
  "verbose_name_plural": str(model._meta.verbose_name_plural),
185
+ # Name of the primary-key field (usually ``id``). The SPA uses
186
+ # it to identify the pk column among ``columns`` so it can pin
187
+ # it first, never truncate it, and keep it from being hidden —
188
+ # the pk is the row's identity and must always be readable in
189
+ # full. May or may not appear in ``list_display``; when it
190
+ # doesn't, the SPA simply has nothing to pin.
191
+ "pk_field": model._meta.pk.name,
137
192
  "permissions": model_permissions(model_admin, request),
138
193
  "columns": columns,
139
194
  "search_fields": list(model_admin.search_fields or ()),
@@ -142,6 +197,14 @@ class ListView(View):
142
197
  "page": page,
143
198
  "page_size": page_size,
144
199
  "total": total,
200
+ # Unfiltered base count when the list is narrowed (else == total);
201
+ # ``null`` when ``show_full_result_count`` is False. The SPA shows
202
+ # "<total> of <full_count>" when they differ (#311).
203
+ "full_count": full_count,
204
+ # ``ModelAdmin.list_max_show_all`` (default 200): the SPA offers a
205
+ # "Show all N" control only when ``total`` is at/below this cap,
206
+ # matching Django's changelist (#385).
207
+ "list_max_show_all": list_max_show_all,
145
208
  "results": results,
146
209
  }
147
210
  date_hierarchy = date_hierarchy_payload(
@@ -146,7 +146,11 @@ class SetPasswordView(View):
146
146
  # rotating the session auth hash (otherwise the password
147
147
  # change would log them straight out).
148
148
  if request.user.pk == obj.pk:
149
- update_session_auth_hash(request, obj)
149
+ # django-stubs types the ``user`` param as the concrete
150
+ # ``User``; ``obj`` is the admin's user model (an
151
+ # ``AbstractBaseUser``), which is correct at runtime. The
152
+ # stub is over-narrow, so ignore just this arg-type.
153
+ update_session_auth_hash(request, obj) # type: ignore[arg-type]
150
154
  # Audit parity: the legacy admin logs a CHANGE with the fixed
151
155
  # "Changed password." message (never the value or a diff of
152
156
  # the password fields). Match it byte-for-byte.