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