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.
- {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a2}/PKG-INFO +16 -17
- {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a2}/README.md +15 -16
- django_admin_react-0.2.0a2/django_admin_react/api/inlines_write.py +226 -0
- {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a2}/django_admin_react/api/registry.py +70 -1
- {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a2}/django_admin_react/api/serializers.py +15 -0
- {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a2}/django_admin_react/api/urls.py +26 -0
- django_admin_react-0.2.0a2/django_admin_react/api/views/auth.py +192 -0
- {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a2}/django_admin_react/api/views/bulk.py +27 -17
- {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a2}/django_admin_react/api/views/create.py +2 -0
- django_admin_react-0.2.0a2/django_admin_react/api/views/delete_preview.py +107 -0
- {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a2}/django_admin_react/api/views/destroy.py +4 -0
- {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a2}/django_admin_react/api/views/detail.py +13 -3
- django_admin_react-0.2.0a2/django_admin_react/api/views/history.py +164 -0
- {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a2}/django_admin_react/api/views/update.py +55 -5
- {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a2}/django_admin_react/api/writes.py +83 -22
- django_admin_react-0.2.0a2/django_admin_react/audit.py +42 -0
- {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a2}/django_admin_react/static/admin_react/.vite/manifest.json +2 -2
- django_admin_react-0.2.0a2/django_admin_react/static/admin_react/assets/index-BE3CZdBI.js +9 -0
- django_admin_react-0.2.0a2/django_admin_react/static/admin_react/assets/index-BE3CZdBI.js.map +1 -0
- django_admin_react-0.2.0a2/django_admin_react/static/admin_react/assets/index-xZLX3uph.css +1 -0
- {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a2}/django_admin_react/static/admin_react/index.html +2 -2
- django_admin_react-0.2.0a2/django_admin_react/templates/admin_react/login.html +76 -0
- {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a2}/django_admin_react/urls.py +11 -2
- {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a2}/django_admin_react/views.py +101 -2
- {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a2}/pyproject.toml +1 -1
- django_admin_react-0.2.0a1/django_admin_react/static/admin_react/assets/index-BxNIuGTs.css +0 -1
- django_admin_react-0.2.0a1/django_admin_react/static/admin_react/assets/index-DSOQeb40.js +0 -9
- django_admin_react-0.2.0a1/django_admin_react/static/admin_react/assets/index-DSOQeb40.js.map +0 -1
- {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a2}/LICENSE +0 -0
- {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a2}/django_admin_react/README.md +0 -0
- {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a2}/django_admin_react/__init__.py +0 -0
- {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a2}/django_admin_react/api/README.md +0 -0
- {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a2}/django_admin_react/api/__init__.py +0 -0
- {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a2}/django_admin_react/api/dates.py +0 -0
- {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a2}/django_admin_react/api/filters.py +0 -0
- {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a2}/django_admin_react/api/inlines.py +0 -0
- {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a2}/django_admin_react/api/panels.py +0 -0
- {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a2}/django_admin_react/api/permissions.py +0 -0
- {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a2}/django_admin_react/api/views/README.md +0 -0
- {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a2}/django_admin_react/api/views/__init__.py +0 -0
- {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a2}/django_admin_react/api/views/actions.py +0 -0
- {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a2}/django_admin_react/api/views/autocomplete.py +0 -0
- {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a2}/django_admin_react/api/views/list.py +0 -0
- {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a2}/django_admin_react/api/views/registry.py +0 -0
- {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a2}/django_admin_react/api/views/schema.py +0 -0
- {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a2}/django_admin_react/apps.py +0 -0
- {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a2}/django_admin_react/conf.py +0 -0
- {django_admin_react-0.2.0a1 → django_admin_react-0.2.0a2}/django_admin_react/templates/admin_react/README.md +0 -0
- {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.
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
|
85
|
-
| -------------------------------------------------- |
|
|
86
|
-
|  |  |
|
|
87
86
|
|
|
88
|
-
|
|
|
89
|
-
|
|
|
90
|
-
|  |  |
|
|
91
90
|
|
|
92
|
-
| Mobile (375 px)
|
|
93
|
-
|
|
|
94
|
-
|  |  |
|
|
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": "
|
|
178
|
-
"BRAND_LOGO_URL": "/static/
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
|
54
|
-
| -------------------------------------------------- |
|
|
55
|
-
|  |  |
|
|
56
55
|
|
|
57
|
-
|
|
|
58
|
-
|
|
|
59
|
-
|  |  |
|
|
60
59
|
|
|
61
|
-
| Mobile (375 px)
|
|
62
|
-
|
|
|
63
|
-
|  |  |
|
|
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": "
|
|
147
|
-
"BRAND_LOGO_URL": "/static/
|
|
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
|
{django_admin_react-0.2.0a1 → django_admin_react-0.2.0a2}/django_admin_react/api/registry.py
RENAMED
|
@@ -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(
|
|
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
|
+
}
|
{django_admin_react-0.2.0a1 → django_admin_react-0.2.0a2}/django_admin_react/api/serializers.py
RENAMED
|
@@ -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(),
|