django-admin-react 0.2.0a1__tar.gz → 0.2.0a2__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 (49) hide show
  1. {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a2}/PKG-INFO +16 -17
  2. {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a2}/README.md +15 -16
  3. django_admin_react-0.2.0a2/django_admin_react/api/inlines_write.py +226 -0
  4. {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a2}/django_admin_react/api/registry.py +70 -1
  5. {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a2}/django_admin_react/api/serializers.py +15 -0
  6. {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a2}/django_admin_react/api/urls.py +26 -0
  7. django_admin_react-0.2.0a2/django_admin_react/api/views/auth.py +192 -0
  8. {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a2}/django_admin_react/api/views/bulk.py +27 -17
  9. {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a2}/django_admin_react/api/views/create.py +2 -0
  10. django_admin_react-0.2.0a2/django_admin_react/api/views/delete_preview.py +107 -0
  11. {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a2}/django_admin_react/api/views/destroy.py +4 -0
  12. {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a2}/django_admin_react/api/views/detail.py +13 -3
  13. django_admin_react-0.2.0a2/django_admin_react/api/views/history.py +164 -0
  14. {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a2}/django_admin_react/api/views/update.py +55 -5
  15. {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a2}/django_admin_react/api/writes.py +83 -22
  16. django_admin_react-0.2.0a2/django_admin_react/audit.py +42 -0
  17. {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a2}/django_admin_react/static/admin_react/.vite/manifest.json +2 -2
  18. django_admin_react-0.2.0a2/django_admin_react/static/admin_react/assets/index-BE3CZdBI.js +9 -0
  19. django_admin_react-0.2.0a2/django_admin_react/static/admin_react/assets/index-BE3CZdBI.js.map +1 -0
  20. django_admin_react-0.2.0a2/django_admin_react/static/admin_react/assets/index-xZLX3uph.css +1 -0
  21. {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a2}/django_admin_react/static/admin_react/index.html +2 -2
  22. django_admin_react-0.2.0a2/django_admin_react/templates/admin_react/login.html +76 -0
  23. {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a2}/django_admin_react/urls.py +11 -2
  24. {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a2}/django_admin_react/views.py +101 -2
  25. {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a2}/pyproject.toml +1 -1
  26. django_admin_react-0.2.0a1/django_admin_react/static/admin_react/assets/index-BxNIuGTs.css +0 -1
  27. django_admin_react-0.2.0a1/django_admin_react/static/admin_react/assets/index-DSOQeb40.js +0 -9
  28. django_admin_react-0.2.0a1/django_admin_react/static/admin_react/assets/index-DSOQeb40.js.map +0 -1
  29. {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a2}/LICENSE +0 -0
  30. {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a2}/django_admin_react/README.md +0 -0
  31. {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a2}/django_admin_react/__init__.py +0 -0
  32. {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a2}/django_admin_react/api/README.md +0 -0
  33. {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a2}/django_admin_react/api/__init__.py +0 -0
  34. {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a2}/django_admin_react/api/dates.py +0 -0
  35. {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a2}/django_admin_react/api/filters.py +0 -0
  36. {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a2}/django_admin_react/api/inlines.py +0 -0
  37. {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a2}/django_admin_react/api/panels.py +0 -0
  38. {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a2}/django_admin_react/api/permissions.py +0 -0
  39. {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a2}/django_admin_react/api/views/README.md +0 -0
  40. {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a2}/django_admin_react/api/views/__init__.py +0 -0
  41. {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a2}/django_admin_react/api/views/actions.py +0 -0
  42. {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a2}/django_admin_react/api/views/autocomplete.py +0 -0
  43. {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a2}/django_admin_react/api/views/list.py +0 -0
  44. {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a2}/django_admin_react/api/views/registry.py +0 -0
  45. {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a2}/django_admin_react/api/views/schema.py +0 -0
  46. {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a2}/django_admin_react/apps.py +0 -0
  47. {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a2}/django_admin_react/conf.py +0 -0
  48. {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a2}/django_admin_react/templates/admin_react/README.md +0 -0
  49. {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a2}/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.0a2
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
 
@@ -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):
@@ -21,11 +21,15 @@ 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.delete_preview import DeletePreviewView
27
30
  from django_admin_react.api.views.destroy import DestroyView
28
31
  from django_admin_react.api.views.detail import DetailView
32
+ from django_admin_react.api.views.history import HistoryView
29
33
  from django_admin_react.api.views.list import ListView
30
34
  from django_admin_react.api.views.registry import RegistryView
31
35
  from django_admin_react.api.views.schema import SchemaView
@@ -77,6 +81,13 @@ class InstanceView(View):
77
81
  urlpatterns: list = [
78
82
  path("registry/", RegistryView.as_view(), name="registry"),
79
83
  path("schema/", SchemaView.as_view(), name="schema"),
84
+ # Auth endpoints (React-login feature). Single-segment literals, so
85
+ # they cannot be shadowed by the two-segment ``<app>/<model>/``
86
+ # pattern below. ``login`` / ``logout`` are also added to
87
+ # ``RESERVED_APP_LABELS`` so a consumer app named ``login`` can't
88
+ # collide. CSRF is enforced by middleware (no ``@csrf_exempt``).
89
+ path("login/", LoginView.as_view(), name="login"),
90
+ path("logout/", LogoutView.as_view(), name="logout"),
80
91
  # Autocomplete is more specific than the collection / instance
81
92
  # patterns below — it must be listed FIRST so the literal
82
93
  # ``/autocomplete/`` segment isn't swallowed as a ``<str:pk>``.
@@ -112,6 +123,21 @@ urlpatterns: list = [
112
123
  PanelView.as_view(),
113
124
  name="panel",
114
125
  ),
126
+ # History sub-resource (#155) — LogEntry timeline for one object.
127
+ # Must precede the instance pattern so ``/history/`` isn't
128
+ # swallowed as part of the ``<pk>`` route.
129
+ path(
130
+ "<str:app_label>/<str:model_name>/<str:pk>/history/",
131
+ HistoryView.as_view(),
132
+ name="history",
133
+ ),
134
+ # Delete-preview sub-resource (#153) — cascade / protected preview
135
+ # before the destructive DELETE. Same ordering caveat as above.
136
+ path(
137
+ "<str:app_label>/<str:model_name>/<str:pk>/delete-preview/",
138
+ DeletePreviewView.as_view(),
139
+ name="delete_preview",
140
+ ),
115
141
  path(
116
142
  "<str:app_label>/<str:model_name>/<str:pk>/",
117
143
  InstanceView.as_view(),