django-admin-react 0.2.0a1__tar.gz → 0.2.0a3__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.0a1 → django_admin_react-0.2.0a3}/PKG-INFO +16 -17
  2. {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a3}/README.md +15 -16
  3. {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a3}/django_admin_react/api/dates.py +1 -5
  4. {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a3}/django_admin_react/api/filters.py +5 -1
  5. {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a3}/django_admin_react/api/inlines.py +3 -9
  6. django_admin_react-0.2.0a3/django_admin_react/api/inlines_write.py +226 -0
  7. {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a3}/django_admin_react/api/registry.py +70 -1
  8. {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a3}/django_admin_react/api/serializers.py +16 -2
  9. {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a3}/django_admin_react/api/urls.py +35 -0
  10. {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a3}/django_admin_react/api/views/actions.py +15 -1
  11. django_admin_react-0.2.0a3/django_admin_react/api/views/auth.py +192 -0
  12. {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a3}/django_admin_react/api/views/autocomplete.py +2 -5
  13. {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a3}/django_admin_react/api/views/bulk.py +27 -17
  14. {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a3}/django_admin_react/api/views/create.py +2 -0
  15. django_admin_react-0.2.0a3/django_admin_react/api/views/create_form.py +96 -0
  16. django_admin_react-0.2.0a3/django_admin_react/api/views/delete_preview.py +107 -0
  17. {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a3}/django_admin_react/api/views/destroy.py +4 -0
  18. {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a3}/django_admin_react/api/views/detail.py +13 -3
  19. django_admin_react-0.2.0a3/django_admin_react/api/views/history.py +164 -0
  20. {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a3}/django_admin_react/api/views/schema.py +4 -14
  21. {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a3}/django_admin_react/api/views/update.py +55 -5
  22. {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a3}/django_admin_react/api/writes.py +83 -22
  23. django_admin_react-0.2.0a3/django_admin_react/audit.py +42 -0
  24. {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a3}/django_admin_react/conf.py +32 -0
  25. django_admin_react-0.2.0a3/django_admin_react/pwa.py +152 -0
  26. {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a3}/django_admin_react/static/admin_react/.vite/manifest.json +2 -2
  27. django_admin_react-0.2.0a3/django_admin_react/static/admin_react/assets/index-BOdTCQF7.js +9 -0
  28. django_admin_react-0.2.0a3/django_admin_react/static/admin_react/assets/index-BOdTCQF7.js.map +1 -0
  29. django_admin_react-0.2.0a3/django_admin_react/static/admin_react/assets/index-BgIZIHRa.css +1 -0
  30. {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a3}/django_admin_react/static/admin_react/index.html +2 -2
  31. django_admin_react-0.2.0a3/django_admin_react/templates/admin_react/login.html +76 -0
  32. django_admin_react-0.2.0a3/django_admin_react/templates/admin_react/sw.js +136 -0
  33. django_admin_react-0.2.0a3/django_admin_react/urls.py +56 -0
  34. {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a3}/django_admin_react/views.py +127 -5
  35. {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a3}/pyproject.toml +1 -1
  36. django_admin_react-0.2.0a1/django_admin_react/static/admin_react/assets/index-BxNIuGTs.css +0 -1
  37. django_admin_react-0.2.0a1/django_admin_react/static/admin_react/assets/index-DSOQeb40.js +0 -9
  38. django_admin_react-0.2.0a1/django_admin_react/static/admin_react/assets/index-DSOQeb40.js.map +0 -1
  39. django_admin_react-0.2.0a1/django_admin_react/urls.py +0 -39
  40. {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a3}/LICENSE +0 -0
  41. {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a3}/django_admin_react/README.md +0 -0
  42. {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a3}/django_admin_react/__init__.py +0 -0
  43. {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a3}/django_admin_react/api/README.md +0 -0
  44. {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a3}/django_admin_react/api/__init__.py +0 -0
  45. {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a3}/django_admin_react/api/panels.py +0 -0
  46. {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a3}/django_admin_react/api/permissions.py +0 -0
  47. {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a3}/django_admin_react/api/views/README.md +0 -0
  48. {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a3}/django_admin_react/api/views/__init__.py +0 -0
  49. {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a3}/django_admin_react/api/views/list.py +0 -0
  50. {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a3}/django_admin_react/api/views/registry.py +0 -0
  51. {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a3}/django_admin_react/apps.py +0 -0
  52. {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a3}/django_admin_react/templates/admin_react/README.md +0 -0
  53. {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a3}/django_admin_react/templates/admin_react/index.html +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: django-admin-react
3
- Version: 0.2.0a1
3
+ Version: 0.2.0a3
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
@@ -75,23 +75,22 @@ permissions at runtime from `GET /api/v1/registry/`. Add a new
75
75
 
76
76
  ## Screenshots
77
77
 
78
- > The React SPA shell is in flight. The screenshots below show the
79
- > **example apps** (`examples/library/`, `examples/fintech/`) rendered
80
- > by the **legacy Django admin** — i.e. the experience this package
81
- > modernises. Once the SPA's v0.1 implementation closes, this section
82
- > regenerates from `docs/screenshots/`.
78
+ Real captures of the **django-admin-react SPA** rendering the bundled
79
+ `examples/` apps driven entirely by each app's `ModelAdmin`.
80
+ Regenerate any time with `scripts/screenshots.sh` (Playwright against a
81
+ throwaway example server).
83
82
 
84
- | Login | Admin index (legacy) |
85
- | -------------------------------------------------- | ------------------------------------------------------- |
86
- | ![Login](docs/screenshots/01-admin-login.png) | ![Admin index](docs/screenshots/02-admin-index.png) |
83
+ | Sign in (package login) | Registry / home |
84
+ | -------------------------------------------------- | ----------------------------------------------------- |
85
+ | ![Sign in](docs/screenshots/01-spa-login.png) | ![Registry](docs/screenshots/02-spa-registry.png) |
87
86
 
88
- | Library list view | Library — detail view |
89
- | -------------------------------------------------------------- | ---------------------------------------------------------------- |
90
- | ![Author list](docs/screenshots/03-admin-library-list.png) | ![Author detail](docs/screenshots/05-admin-library-detail.png) |
87
+ | List view (`list_display` + search) | Detail view |
88
+ | ------------------------------------------------------- | ---------------------------------------------------- |
89
+ | ![List](docs/screenshots/03-spa-list.png) | ![Detail](docs/screenshots/05-spa-detail.png) |
91
90
 
92
- | Mobile (375 px) — `list_display` collapsed | API: `GET /api/v1/registry/` |
93
- | ------------------------------------------------------------------- | ------------------------------------------------------------- |
94
- | ![Mobile list](docs/screenshots/04-admin-library-list-mobile.png) | ![Registry JSON](docs/screenshots/06-registry-api-json.png) |
91
+ | Mobile (375 px) | API: `GET /api/v1/registry/` |
92
+ | ---------------------------------------------------------- | ---------------------------------------------------------- |
93
+ | ![Mobile](docs/screenshots/04-spa-list-mobile.png) | ![Registry JSON](docs/screenshots/06-registry-api-json.png) |
95
94
 
96
95
  Screenshots use deterministic synthetic fixtures (no real names,
97
96
  emails, account numbers, or PII).
@@ -174,8 +173,8 @@ the brand title in the sidebar.
174
173
  ```python
175
174
  # settings.py
176
175
  DJANGO_ADMIN_REACT = {
177
- "BRAND_TITLE": "Laminr",
178
- "BRAND_LOGO_URL": "/static/laminr/logo.svg",
176
+ "BRAND_TITLE": "Acme",
177
+ "BRAND_LOGO_URL": "/static/acme/logo.svg",
179
178
  }
180
179
  ```
181
180
 
@@ -44,23 +44,22 @@ permissions at runtime from `GET /api/v1/registry/`. Add a new
44
44
 
45
45
  ## Screenshots
46
46
 
47
- > The React SPA shell is in flight. The screenshots below show the
48
- > **example apps** (`examples/library/`, `examples/fintech/`) rendered
49
- > by the **legacy Django admin** — i.e. the experience this package
50
- > modernises. Once the SPA's v0.1 implementation closes, this section
51
- > regenerates from `docs/screenshots/`.
47
+ Real captures of the **django-admin-react SPA** rendering the bundled
48
+ `examples/` apps driven entirely by each app's `ModelAdmin`.
49
+ Regenerate any time with `scripts/screenshots.sh` (Playwright against a
50
+ throwaway example server).
52
51
 
53
- | Login | Admin index (legacy) |
54
- | -------------------------------------------------- | ------------------------------------------------------- |
55
- | ![Login](docs/screenshots/01-admin-login.png) | ![Admin index](docs/screenshots/02-admin-index.png) |
52
+ | Sign in (package login) | Registry / home |
53
+ | -------------------------------------------------- | ----------------------------------------------------- |
54
+ | ![Sign in](docs/screenshots/01-spa-login.png) | ![Registry](docs/screenshots/02-spa-registry.png) |
56
55
 
57
- | Library list view | Library — detail view |
58
- | -------------------------------------------------------------- | ---------------------------------------------------------------- |
59
- | ![Author list](docs/screenshots/03-admin-library-list.png) | ![Author detail](docs/screenshots/05-admin-library-detail.png) |
56
+ | List view (`list_display` + search) | Detail view |
57
+ | ------------------------------------------------------- | ---------------------------------------------------- |
58
+ | ![List](docs/screenshots/03-spa-list.png) | ![Detail](docs/screenshots/05-spa-detail.png) |
60
59
 
61
- | Mobile (375 px) — `list_display` collapsed | API: `GET /api/v1/registry/` |
62
- | ------------------------------------------------------------------- | ------------------------------------------------------------- |
63
- | ![Mobile list](docs/screenshots/04-admin-library-list-mobile.png) | ![Registry JSON](docs/screenshots/06-registry-api-json.png) |
60
+ | Mobile (375 px) | API: `GET /api/v1/registry/` |
61
+ | ---------------------------------------------------------- | ---------------------------------------------------------- |
62
+ | ![Mobile](docs/screenshots/04-spa-list-mobile.png) | ![Registry JSON](docs/screenshots/06-registry-api-json.png) |
64
63
 
65
64
  Screenshots use deterministic synthetic fixtures (no real names,
66
65
  emails, account numbers, or PII).
@@ -143,8 +142,8 @@ the brand title in the sidebar.
143
142
  ```python
144
143
  # settings.py
145
144
  DJANGO_ADMIN_REACT = {
146
- "BRAND_TITLE": "Laminr",
147
- "BRAND_LOGO_URL": "/static/laminr/logo.svg",
145
+ "BRAND_TITLE": "Acme",
146
+ "BRAND_LOGO_URL": "/static/acme/logo.svg",
148
147
  }
149
148
  ```
150
149
 
@@ -143,11 +143,7 @@ def build_buckets(
143
143
  .annotate(_count=Count("pk"))
144
144
  .order_by("_bucket")
145
145
  )
146
- return [
147
- {"value": r["_bucket"], "count": r["_count"]}
148
- for r in rows
149
- if r["_bucket"] is not None
150
- ]
146
+ return [{"value": r["_bucket"], "count": r["_count"]} for r in rows if r["_bucket"] is not None]
151
147
 
152
148
 
153
149
  def date_hierarchy_payload(
@@ -34,6 +34,7 @@ Hard rules (`SECURITY.md` §3):
34
34
 
35
35
  from __future__ import annotations
36
36
 
37
+ import logging
37
38
  from typing import Any
38
39
 
39
40
  from django.contrib.admin import SimpleListFilter
@@ -49,6 +50,8 @@ from django.http import HttpRequest
49
50
 
50
51
  from django_admin_react.api.serializers import is_sensitive_field_name
51
52
 
53
+ logger = logging.getLogger(__name__)
54
+
52
55
  # PM ruling (Q-PM-03): FK filters in v1 surface up to ≤ 25 options
53
56
  # inline; larger target tables defer to a follow-up that combines
54
57
  # list_filter with autocomplete (#59). Keep the cap explicit so a
@@ -273,7 +276,8 @@ def apply_filters(queryset: QuerySet, model_admin: ModelAdmin, request: HttpRequ
273
276
  if filter_cls is not None and issubclass(filter_cls, SimpleListFilter):
274
277
  try:
275
278
  instance = filter_cls(request, request.GET.copy(), model_admin.model, model_admin)
276
- except Exception: # pragma: no cover
279
+ except Exception: # pragma: no cover - skip a misbehaving consumer filter
280
+ logger.debug("Skipping list_filter %r: instantiation failed", entry, exc_info=True)
277
281
  continue
278
282
  try:
279
283
  narrowed = instance.queryset(request, queryset)
@@ -124,9 +124,7 @@ def _spec_for_inline(
124
124
 
125
125
  rows: list[dict[str, Any]] = []
126
126
  if can_view:
127
- rows = _rows_for_inline(
128
- inline, parent, fk_name, visible_fields, request
129
- )
127
+ rows = _rows_for_inline(inline, parent, fk_name, visible_fields, request)
130
128
 
131
129
  return {
132
130
  "name": fk_name + "_set" if not hasattr(child_model, fk_name + "_set") else fk_name,
@@ -183,9 +181,7 @@ def _visible_inline_fields(
183
181
  visible = [
184
182
  name
185
183
  for name in declared
186
- if name not in excluded
187
- and name != fk_back
188
- and not is_sensitive_field_name(name)
184
+ if name not in excluded and name != fk_back and not is_sensitive_field_name(name)
189
185
  ]
190
186
  return filter_sensitive(visible)
191
187
 
@@ -246,7 +242,5 @@ def _rows_for_inline(
246
242
  fields_payload[name] = [serialize_fk_value(r) for r in related]
247
243
  else:
248
244
  fields_payload[name] = serialize_value(value, field=model_field)
249
- rows.append(
250
- {"pk": obj.pk, "label": label_for(obj), "fields": fields_payload}
251
- )
245
+ rows.append({"pk": obj.pk, "label": label_for(obj), "fields": fields_payload})
252
246
  return rows
@@ -0,0 +1,226 @@
1
+ """Inline formset write path (Issue #54, write half — PR 2 of the split).
2
+
3
+ Wire contract: ``docs/api-contract.md`` §5.4 (inline writes).
4
+
5
+ The read half (#109, ``api/inlines.py``) surfaces each declared
6
+ ``InlineModelAdmin`` and its existing rows on the detail response. This
7
+ module is the **write** counterpart: it takes the ``inlines`` block of a
8
+ PATCH/POST payload and round-trips it through Django's own inline
9
+ formset machinery, exactly the way ``ModelAdmin.changeform_view`` does.
10
+
11
+ Why a formset and not a per-row ``save()`` loop (Architect rule 3):
12
+ iterating rows and calling ``child.save()`` each would bypass the
13
+ formset's ``clean()`` / ``clean_m2m()`` and the inline's
14
+ ``save_formset`` hook — losing the consumer's cross-row validation and
15
+ any signals they rely on. We build the real formset, validate it as a
16
+ unit, and call ``model_admin.save_formset(...)``.
17
+
18
+ Security model (Architect contract + `SECURITY.md` §3):
19
+
20
+ - **Rule 1** — everything reuses ``InlineModelAdmin``; no parallel
21
+ inline-config or write path.
22
+ - **Rule 3** — rows round-trip through
23
+ ``inline.get_formset(request, obj=parent)`` + ``formset.save()``.
24
+ - **Per-row permission gates** — each row's *state* is gated by the
25
+ inline's own permission method against the **parent** object:
26
+ - a new row (``pk`` is null) requires ``has_add_permission``;
27
+ - an edited existing row requires ``has_change_permission``;
28
+ - a ``DELETE`` row requires ``has_delete_permission``.
29
+ A single failing gate makes the **whole** PATCH roll back — the
30
+ caller wraps this in ``transaction.atomic()`` and treats a returned
31
+ ``PermissionError`` as a 403 that reverts the parent write too.
32
+ - **Deny-by-default lookup** — an ``inlines`` key that doesn't match a
33
+ declared inline on this parent is rejected (400), never silently
34
+ ignored (no mass-assignment via an unrecognised prefix).
35
+
36
+ Out of scope (firm — documented in the PR and the contract):
37
+
38
+ - Nested inlines (inline-of-inline).
39
+ - ``GenericInlineModelAdmin`` (contenttypes).
40
+ - M2M-through inlines with extra fields (surfaced as ``unsupported`` by
41
+ the read half; writes are refused here for the same reason).
42
+ """
43
+
44
+ from __future__ import annotations
45
+
46
+ from typing import Any
47
+
48
+ from django.contrib.admin.options import InlineModelAdmin
49
+ from django.contrib.admin.options import ModelAdmin
50
+ from django.db.models import Model
51
+ from django.forms.models import BaseModelFormSet
52
+ from django.http import HttpRequest
53
+
54
+ from django_admin_react.api.inlines import _get_inline_instances
55
+ from django_admin_react.api.inlines import _resolve_fk_name
56
+
57
+
58
+ class InlinePermissionDenied(Exception):
59
+ """A per-row state was not permitted for the requesting user.
60
+
61
+ Raised (not returned) so the caller's ``transaction.atomic()`` block
62
+ unwinds the parent write as well — a forbidden inline row must never
63
+ leave a half-applied PATCH behind. The caller converts this to a 403.
64
+ """
65
+
66
+ def __init__(self, inline_name: str, state: str) -> None:
67
+ super().__init__(f"inline {inline_name!r}: {state} not permitted")
68
+ self.inline_name = inline_name
69
+ self.state = state
70
+
71
+
72
+ def _inline_name(inline: InlineModelAdmin, parent: Model) -> str:
73
+ """The identifier the read half emits for this inline.
74
+
75
+ Must match ``inlines.py``'s ``_spec_for_inline`` so the SPA can echo
76
+ the same key back on write. Kept in one place would be ideal; this
77
+ mirrors the read-half computation deliberately and the
78
+ ``test_inline_write_name_matches_read`` regression pins them
79
+ together.
80
+ """
81
+ child_model = inline.model
82
+ fk_name = _resolve_fk_name(inline, parent)
83
+ if fk_name is None:
84
+ return child_model._meta.model_name
85
+ if hasattr(child_model, fk_name + "_set"):
86
+ return fk_name
87
+ return fk_name + "_set"
88
+
89
+
90
+ def _formset_data_for(prefix: str, items: list[dict[str, Any]]) -> dict[str, str]:
91
+ """Translate JSON inline rows into Django formset POST-style data.
92
+
93
+ Django's ``BaseModelFormSet`` treats the first ``INITIAL_FORMS``
94
+ forms as *existing* (keyed by their ``id`` field) and the rest as
95
+ *new*. So the items are ordered **existing-first** by the caller and
96
+ ``INITIAL_FORMS`` is set to the count of rows carrying a ``pk``.
97
+
98
+ Scalar values are stringified (the form fields coerce them back);
99
+ ``None`` becomes the empty string the way an empty HTML input would.
100
+ A truthy ``DELETE`` flag sets the formset's per-form ``DELETE``
101
+ checkbox.
102
+ """
103
+ initial = sum(1 for it in items if it.get("pk") is not None)
104
+ data: dict[str, str] = {
105
+ f"{prefix}-TOTAL_FORMS": str(len(items)),
106
+ f"{prefix}-INITIAL_FORMS": str(initial),
107
+ f"{prefix}-MIN_NUM_FORMS": "0",
108
+ f"{prefix}-MAX_NUM_FORMS": "1000",
109
+ }
110
+ for i, item in enumerate(items):
111
+ pk = item.get("pk")
112
+ if pk is not None:
113
+ data[f"{prefix}-{i}-id"] = str(pk)
114
+ for fname, fval in (item.get("fields") or {}).items():
115
+ data[f"{prefix}-{i}-{fname}"] = "" if fval is None else str(fval)
116
+ if item.get("DELETE"):
117
+ data[f"{prefix}-{i}-DELETE"] = "on"
118
+ return data
119
+
120
+
121
+ def _ordered_items(raw_items: Any) -> list[dict[str, Any]]:
122
+ """Validate + order the incoming row list: existing (``pk``) first.
123
+
124
+ Raises ``ValueError`` on a malformed payload (not a list, or a row
125
+ that isn't an object) so the caller returns a 400 rather than a 500.
126
+ """
127
+ if not isinstance(raw_items, list):
128
+ raise ValueError("inline 'items' must be a list")
129
+ items: list[dict[str, Any]] = []
130
+ for row in raw_items:
131
+ if not isinstance(row, dict):
132
+ raise ValueError("each inline row must be an object")
133
+ items.append(row)
134
+ existing = [it for it in items if it.get("pk") is not None]
135
+ new = [it for it in items if it.get("pk") is None]
136
+ return existing + new
137
+
138
+
139
+ def _gate_row_states(
140
+ inline: InlineModelAdmin,
141
+ parent: Model,
142
+ request: HttpRequest,
143
+ items: list[dict[str, Any]],
144
+ name: str,
145
+ ) -> None:
146
+ """Raise ``InlinePermissionDenied`` if any row state isn't allowed.
147
+
148
+ Gates **before** the formset saves so a forbidden state never
149
+ partially persists. Checks the inline's own permission methods
150
+ against the parent object (not the parent admin's) per the Architect
151
+ contract.
152
+ """
153
+ wants_add = any(it.get("pk") is None and not it.get("DELETE") for it in items)
154
+ wants_change = any(it.get("pk") is not None and not it.get("DELETE") for it in items)
155
+ wants_delete = any(it.get("DELETE") for it in items)
156
+
157
+ if wants_add and not inline.has_add_permission(request, parent):
158
+ raise InlinePermissionDenied(name, "add")
159
+ if wants_change and not inline.has_change_permission(request, parent):
160
+ raise InlinePermissionDenied(name, "change")
161
+ if wants_delete and not inline.has_delete_permission(request, parent):
162
+ raise InlinePermissionDenied(name, "delete")
163
+
164
+
165
+ def apply_inline_writes(
166
+ model_admin: ModelAdmin,
167
+ request: HttpRequest,
168
+ parent: Model,
169
+ parent_form: Any,
170
+ inlines_payload: dict[str, Any],
171
+ ) -> dict[str, dict[str, Any]] | None:
172
+ """Validate + save every inline formset in ``inlines_payload``.
173
+
174
+ Returns ``None`` on success. On formset validation failure returns
175
+ an errors dict keyed by inline name (so the caller returns 400 and
176
+ rolls back). Raises :class:`InlinePermissionDenied` on a forbidden
177
+ row state (caller → 403 + rollback). Raises ``ValueError`` on a
178
+ malformed payload shape (caller → 400).
179
+
180
+ Must be called **inside** the caller's ``transaction.atomic()``
181
+ block, after the parent form has saved, so a failure here reverts
182
+ the parent write too.
183
+ """
184
+ if not isinstance(inlines_payload, dict):
185
+ raise ValueError("'inlines' must be an object keyed by inline name")
186
+
187
+ # Map declared inlines by the read-half name so an unknown key is a
188
+ # 400 (deny-by-default) rather than a silently-ignored payload.
189
+ inlines = _get_inline_instances(model_admin, parent, request)
190
+ by_name: dict[str, InlineModelAdmin] = {
191
+ _inline_name(inline, parent): inline for inline in inlines
192
+ }
193
+ unknown = set(inlines_payload) - set(by_name)
194
+ if unknown:
195
+ raise ValueError("unknown inline(s): " + ", ".join(sorted(unknown)))
196
+
197
+ errors: dict[str, dict[str, Any]] = {}
198
+
199
+ for name, block in inlines_payload.items():
200
+ inline = by_name[name]
201
+ if not isinstance(block, dict):
202
+ raise ValueError(f"inline {name!r} must be an object with 'items'")
203
+ items = _ordered_items(block.get("items", []))
204
+ if not items:
205
+ continue
206
+
207
+ # Per-row permission gate BEFORE building/saving the formset.
208
+ _gate_row_states(inline, parent, request, items, name)
209
+
210
+ formset_class = inline.get_formset(request, obj=parent)
211
+ prefix = formset_class.get_default_prefix()
212
+ formset: BaseModelFormSet = formset_class(
213
+ data=_formset_data_for(prefix, items),
214
+ instance=parent,
215
+ prefix=prefix,
216
+ )
217
+ if not formset.is_valid():
218
+ errors[name] = {"formset": formset.errors, "non_form": list(formset.non_form_errors())}
219
+ continue
220
+
221
+ # Round-trip through the admin's save hook (rule 3) — never a
222
+ # per-row save loop. ``save_formset`` runs the consumer's
223
+ # ``save_formset`` override + ``save_m2m`` for the children.
224
+ model_admin.save_formset(request, parent_form, formset, change=True)
225
+
226
+ return errors or None
@@ -236,7 +236,9 @@ def _app_verbose_name(app_label: str) -> str:
236
236
  # does) or, worse, surface a consumer model whose URL the SPA can
237
237
  # never reach. Treat the segment as reserved and 404 instead — same
238
238
  # posture as an unregistered model. Closes issue #93.
239
- RESERVED_APP_LABELS: frozenset[str] = frozenset({"registry", "schema", "session"})
239
+ RESERVED_APP_LABELS: frozenset[str] = frozenset(
240
+ {"registry", "schema", "session", "login", "logout"}
241
+ )
240
242
 
241
243
 
242
244
  def resolve_model(
@@ -283,3 +285,70 @@ def resolve_model(
283
285
  def model_permissions(model_admin: ModelAdmin, request: HttpRequest) -> dict[str, bool]:
284
286
  """Public alias for the four ``has_*_permission`` booleans."""
285
287
  return _model_permissions(model_admin, request)
288
+
289
+
290
+ def save_options(
291
+ model_admin: ModelAdmin,
292
+ request: HttpRequest,
293
+ obj: Model | None = None,
294
+ ) -> dict[str, bool]:
295
+ """Visibility of the four Django save-flow buttons for this view (#154).
296
+
297
+ Mirrors the logic Django's ``admin_modify.submit_row`` template tag
298
+ applies, restricted to the two views this package serves:
299
+
300
+ - ``obj is not None`` → **change view** (``add=False, change=True``).
301
+ - ``obj is None`` → **add/create view** (``add=True, change=False``).
302
+
303
+ We compute the flags from ``ModelAdmin`` permission methods +
304
+ ``ModelAdmin.save_as`` rather than rendering the admin template, so
305
+ the package never depends on the admin template context. The flag
306
+ set is the source of truth for which buttons the SPA renders; the
307
+ SPA never invents a save routing the backend wouldn't allow.
308
+
309
+ Returned keys (all booleans):
310
+
311
+ - ``show_save`` — the plain "Save" button.
312
+ - ``show_save_and_continue`` — "Save and continue editing".
313
+ - ``show_save_and_add_another`` — "Save and add another".
314
+ - ``show_save_as_new`` — "Save as new" (change view only, and only
315
+ when ``ModelAdmin.save_as`` is True).
316
+ - ``save_as`` — the raw ``ModelAdmin.save_as`` flag, surfaced so the
317
+ SPA knows whether a "Save as new" POST creates a fresh object.
318
+ - ``save_as_continue`` — the raw ``ModelAdmin.save_as_continue``
319
+ flag (default True): after a "Save as new", whether the SPA
320
+ lands on the new object's change view (True) or the changelist
321
+ (False).
322
+
323
+ ``has_editable_inline_admin_formsets`` is **not** factored in here
324
+ (the package's inline write-half is tracked under #54). Until that
325
+ lands, ``can_save`` reduces to the object-level change/add
326
+ permission, which is correct for models without editable inlines —
327
+ the overwhelming common case.
328
+ """
329
+ is_change = obj is not None
330
+ is_add = not is_change
331
+ save_as = bool(getattr(model_admin, "save_as", False))
332
+ save_as_continue = bool(getattr(model_admin, "save_as_continue", True))
333
+
334
+ has_add = bool(model_admin.has_add_permission(request))
335
+ has_change = bool(model_admin.has_change_permission(request, obj))
336
+ has_view = bool(model_admin.has_view_permission(request, obj))
337
+
338
+ # Django: can_save = (has_change and change) or (has_add and add).
339
+ can_save = (has_change and is_change) or (has_add and is_add)
340
+ # Django: can_save_and_add_another = has_add and (not save_as or add) and can_save.
341
+ can_add_another = has_add and (not save_as or is_add) and can_save
342
+ # Django: can_save_and_continue = can_save and has_view (not is_popup; we never pop up).
343
+ can_continue = can_save and has_view
344
+ # Django: show_save_as_new = has_change and change and save_as.
345
+ show_save_as_new = has_change and is_change and save_as
346
+
347
+ return {
348
+ "show_save": can_save,
349
+ "show_save_and_continue": can_continue,
350
+ "show_save_and_add_another": can_add_another,
351
+ "show_save_as_new": show_save_as_new,
352
+ "save_as": save_as,
353
+ "save_as_continue": save_as_continue,
354
+ }
@@ -30,6 +30,7 @@ from django.db.models import Field
30
30
  from django.db.models import ForeignKey
31
31
  from django.db.models import ManyToManyField
32
32
  from django.db.models import Model
33
+ from django.utils.safestring import SafeString
33
34
 
34
35
  SENSITIVE_NAME_SUBSTRINGS: Final[tuple[str, ...]] = (
35
36
  "password",
@@ -71,6 +72,20 @@ def serialize_value(value: Any, field: Field | None = None) -> Any:
71
72
  custom = _registered_serializer(field)
72
73
  if custom is not None:
73
74
  return custom(value)
75
+ # SafeString FIRST — it subclasses ``str``, so it must be detected
76
+ # before the plain-``str`` pass-through below. A ``SafeString`` is
77
+ # produced by ``format_html`` / ``mark_safe``, which is how a
78
+ # ``ModelAdmin`` ``list_display`` method (or a readonly display
79
+ # method) opts a value into being rendered as HTML in Django's own
80
+ # changelist. We mirror that: emit a typed ``{"html": ...}``
81
+ # envelope so the SPA renders it as markup. A *plain* ``str`` —
82
+ # e.g. a ``CharField`` containing ``"<script>"`` — is NOT a
83
+ # ``SafeString`` and stays inert text (rendered escaped by React).
84
+ # Trust boundary is identical to Django's: the admin author marked
85
+ # it safe; interpolated args in ``format_html`` are auto-escaped.
86
+ # See docs/api-contract.md §4 + SECURITY.md (Closes #172).
87
+ if isinstance(value, SafeString):
88
+ return {"html": str(value)}
74
89
  if value is None or isinstance(value, bool | int | float | str):
75
90
  return value
76
91
  if isinstance(value, decimal.Decimal):
@@ -138,8 +153,7 @@ def _looks_like_range(value: Any) -> bool:
138
153
  dependency.
139
154
  """
140
155
  return all(
141
- hasattr(value, attr)
142
- for attr in ("lower", "upper", "lower_inc", "upper_inc", "isempty")
156
+ hasattr(value, attr) for attr in ("lower", "upper", "lower_inc", "upper_inc", "isempty")
143
157
  )
144
158
 
145
159
 
@@ -21,11 +21,16 @@ from django.views.generic import View
21
21
 
22
22
  from django_admin_react.api.panels import PanelView
23
23
  from django_admin_react.api.views.actions import ActionView
24
+ from django_admin_react.api.views.auth import LoginView
25
+ from django_admin_react.api.views.auth import LogoutView
24
26
  from django_admin_react.api.views.autocomplete import AutocompleteView
25
27
  from django_admin_react.api.views.bulk import BulkUpdateView
26
28
  from django_admin_react.api.views.create import CreateView
29
+ from django_admin_react.api.views.create_form import AddFormView
30
+ from django_admin_react.api.views.delete_preview import DeletePreviewView
27
31
  from django_admin_react.api.views.destroy import DestroyView
28
32
  from django_admin_react.api.views.detail import DetailView
33
+ from django_admin_react.api.views.history import HistoryView
29
34
  from django_admin_react.api.views.list import ListView
30
35
  from django_admin_react.api.views.registry import RegistryView
31
36
  from django_admin_react.api.views.schema import SchemaView
@@ -77,6 +82,13 @@ class InstanceView(View):
77
82
  urlpatterns: list = [
78
83
  path("registry/", RegistryView.as_view(), name="registry"),
79
84
  path("schema/", SchemaView.as_view(), name="schema"),
85
+ # Auth endpoints (React-login feature). Single-segment literals, so
86
+ # they cannot be shadowed by the two-segment ``<app>/<model>/``
87
+ # pattern below. ``login`` / ``logout`` are also added to
88
+ # ``RESERVED_APP_LABELS`` so a consumer app named ``login`` can't
89
+ # collide. CSRF is enforced by middleware (no ``@csrf_exempt``).
90
+ path("login/", LoginView.as_view(), name="login"),
91
+ path("logout/", LogoutView.as_view(), name="logout"),
80
92
  # Autocomplete is more specific than the collection / instance
81
93
  # patterns below — it must be listed FIRST so the literal
82
94
  # ``/autocomplete/`` segment isn't swallowed as a ``<str:pk>``.
@@ -99,6 +111,14 @@ urlpatterns: list = [
99
111
  BulkUpdateView.as_view(),
100
112
  name="bulk_update",
101
113
  ),
114
+ # Add-form schema — the create page's field descriptors for a NEW
115
+ # object. Literal ``add`` must precede the ``<pk>`` instance route
116
+ # below so it isn't swallowed as a pk.
117
+ path(
118
+ "<str:app_label>/<str:model_name>/add/",
119
+ AddFormView.as_view(),
120
+ name="add_form",
121
+ ),
102
122
  path(
103
123
  "<str:app_label>/<str:model_name>/",
104
124
  CollectionView.as_view(),
@@ -112,6 +132,21 @@ urlpatterns: list = [
112
132
  PanelView.as_view(),
113
133
  name="panel",
114
134
  ),
135
+ # History sub-resource (#155) — LogEntry timeline for one object.
136
+ # Must precede the instance pattern so ``/history/`` isn't
137
+ # swallowed as part of the ``<pk>`` route.
138
+ path(
139
+ "<str:app_label>/<str:model_name>/<str:pk>/history/",
140
+ HistoryView.as_view(),
141
+ name="history",
142
+ ),
143
+ # Delete-preview sub-resource (#153) — cascade / protected preview
144
+ # before the destructive DELETE. Same ordering caveat as above.
145
+ path(
146
+ "<str:app_label>/<str:model_name>/<str:pk>/delete-preview/",
147
+ DeletePreviewView.as_view(),
148
+ name="delete_preview",
149
+ ),
115
150
  path(
116
151
  "<str:app_label>/<str:model_name>/<str:pk>/",
117
152
  InstanceView.as_view(),
@@ -31,6 +31,7 @@ from __future__ import annotations
31
31
 
32
32
  from typing import Any
33
33
 
34
+ from django.contrib.admin.utils import model_format_dict
34
35
  from django.db import transaction
35
36
  from django.http import HttpRequest
36
37
  from django.http import HttpResponse
@@ -138,9 +139,22 @@ def actions_payload(model_admin: Any, request: HttpRequest) -> list[dict[str, An
138
139
  confirmation step regardless — this hint is a UX optimisation.
139
140
  """
140
141
  raw = model_admin.get_actions(request) or {}
142
+ # Django's built-in `delete_selected` (and any action whose
143
+ # `short_description` uses the admin's `%(verbose_name)s` /
144
+ # `%(verbose_name_plural)s` placeholders) ships a *format string*,
145
+ # not a finished label — Django interpolates it at render time via
146
+ # `model_format_dict(opts)`. Do the same here so the SPA shows
147
+ # "Delete selected files", never the raw "%(verbose_name_plural)s".
148
+ fmt = model_format_dict(model_admin.model._meta)
141
149
  out: list[dict[str, Any]] = []
142
150
  for name, (_callable, _resolved_name, description) in raw.items():
143
- label = str(description) if description else name
151
+ raw_label = str(description) if description else name
152
+ try:
153
+ label = raw_label % fmt
154
+ except (KeyError, ValueError, TypeError):
155
+ # Not a %-format string, or references a key we don't
156
+ # provide — surface the label verbatim rather than crashing.
157
+ label = raw_label
144
158
  requires_conf = "delete" in (label.lower() + " " + name.lower())
145
159
  out.append(
146
160
  {