django-admin-react 0.1.0a1__py3-none-any.whl
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.
- LICENSE +21 -0
- django_admin_react/README.md +57 -0
- django_admin_react/__init__.py +10 -0
- django_admin_react/api/README.md +31 -0
- django_admin_react/api/__init__.py +5 -0
- django_admin_react/api/permissions.py +80 -0
- django_admin_react/api/registry.py +200 -0
- django_admin_react/api/serializers.py +183 -0
- django_admin_react/api/urls.py +84 -0
- django_admin_react/api/views/README.md +21 -0
- django_admin_react/api/views/__init__.py +6 -0
- django_admin_react/api/views/create.py +141 -0
- django_admin_react/api/views/destroy.py +89 -0
- django_admin_react/api/views/detail.py +283 -0
- django_admin_react/api/views/list.py +271 -0
- django_admin_react/api/views/registry.py +51 -0
- django_admin_react/api/views/update.py +121 -0
- django_admin_react/api/writes.py +325 -0
- django_admin_react/apps.py +27 -0
- django_admin_react/conf.py +77 -0
- django_admin_react/static/admin_react/.vite/manifest.json +11 -0
- django_admin_react/static/admin_react/assets/index-CKxeWYBA.css +1 -0
- django_admin_react/static/admin_react/assets/index-itk7hrnq.js +68 -0
- django_admin_react/static/admin_react/assets/index-itk7hrnq.js.map +1 -0
- django_admin_react/static/admin_react/index.html +13 -0
- django_admin_react/templates/admin_react/README.md +10 -0
- django_admin_react/templates/admin_react/index.html +33 -0
- django_admin_react/urls.py +39 -0
- django_admin_react/views.py +136 -0
- django_admin_react-0.1.0a1.dist-info/LICENSE +21 -0
- django_admin_react-0.1.0a1.dist-info/METADATA +237 -0
- django_admin_react-0.1.0a1.dist-info/RECORD +33 -0
- django_admin_react-0.1.0a1.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"""GET /api/v1/<app>/<model>/ — list view.
|
|
2
|
+
|
|
3
|
+
Wire contract: ``docs/api-contract.md`` §3.
|
|
4
|
+
|
|
5
|
+
Hard rules followed (`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 5: ``has_view_permission`` controls visibility.
|
|
10
|
+
- Rule 10: Queryset starts at ``ModelAdmin.get_queryset(request)`` —
|
|
11
|
+
never ``Model.objects.all()`` (B-2).
|
|
12
|
+
- Search: ``ModelAdmin.get_search_results(request, qs, q)``.
|
|
13
|
+
- Columns: ``ModelAdmin.get_list_display(request)``.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from django.contrib.admin.options import ModelAdmin
|
|
21
|
+
from django.contrib.admin.utils import label_for_field
|
|
22
|
+
from django.contrib.admin.utils import lookup_field
|
|
23
|
+
from django.db.models import ForeignKey
|
|
24
|
+
from django.db.models import Model
|
|
25
|
+
from django.db.models import QuerySet
|
|
26
|
+
from django.http import HttpRequest
|
|
27
|
+
from django.http import HttpResponse
|
|
28
|
+
from django.http import JsonResponse
|
|
29
|
+
from django.views.generic import View
|
|
30
|
+
|
|
31
|
+
from django_admin_react import conf
|
|
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 model_permissions
|
|
36
|
+
from django_admin_react.api.registry import resolve_model
|
|
37
|
+
from django_admin_react.api.serializers import label_for
|
|
38
|
+
from django_admin_react.api.serializers import serialize_fk_value
|
|
39
|
+
from django_admin_react.api.serializers import serialize_value
|
|
40
|
+
from django_admin_react.api.writes import not_found_response
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ListView(View):
|
|
44
|
+
"""``GET /api/v1/<app_label>/<model_name>/`` — paginated list."""
|
|
45
|
+
|
|
46
|
+
http_method_names = ["get"]
|
|
47
|
+
|
|
48
|
+
def get( # noqa: D401
|
|
49
|
+
self,
|
|
50
|
+
request: HttpRequest,
|
|
51
|
+
app_label: str,
|
|
52
|
+
model_name: str,
|
|
53
|
+
*args: Any,
|
|
54
|
+
**kwargs: Any,
|
|
55
|
+
) -> HttpResponse:
|
|
56
|
+
"""Return a paginated list of rows for one model (contract §3).
|
|
57
|
+
|
|
58
|
+
Gates, in order:
|
|
59
|
+
|
|
60
|
+
1. ``is_admin_user`` — 403 if not authenticated active staff.
|
|
61
|
+
2. ``resolve_model`` — 404 if the model isn't registered with
|
|
62
|
+
the admin site or the user can't view it. Returning 404
|
|
63
|
+
(not 403) is deliberate so the endpoint never reveals
|
|
64
|
+
"this model exists but you can't see it" (rule 12 /
|
|
65
|
+
ACCEPTANCE §4.3 S-11).
|
|
66
|
+
3. ``ModelAdmin.get_queryset(request)`` provides the starting
|
|
67
|
+
queryset — never ``Model.objects.all()`` (rule 10 / B-2).
|
|
68
|
+
|
|
69
|
+
Then applies search, ordering, page-size clamp, and serializes
|
|
70
|
+
each row through the conservative serializer.
|
|
71
|
+
"""
|
|
72
|
+
admin_site = get_admin_site()
|
|
73
|
+
if not is_admin_user(request, admin_site=admin_site):
|
|
74
|
+
return forbidden_response()
|
|
75
|
+
|
|
76
|
+
resolved = resolve_model(admin_site, request, app_label, model_name)
|
|
77
|
+
if resolved is None:
|
|
78
|
+
return not_found_response()
|
|
79
|
+
model, model_admin = resolved
|
|
80
|
+
|
|
81
|
+
queryset = model_admin.get_queryset(request)
|
|
82
|
+
|
|
83
|
+
q = request.GET.get("q", "") or ""
|
|
84
|
+
if q and model_admin.search_fields:
|
|
85
|
+
queryset, may_have_duplicates = model_admin.get_search_results(request, queryset, q)
|
|
86
|
+
if may_have_duplicates:
|
|
87
|
+
queryset = queryset.distinct()
|
|
88
|
+
|
|
89
|
+
queryset = _apply_ordering(queryset, model_admin, request)
|
|
90
|
+
|
|
91
|
+
total = queryset.count()
|
|
92
|
+
|
|
93
|
+
page_size = _clamp_page_size(request.GET.get("page_size"))
|
|
94
|
+
page = _clamp_page(request.GET.get("page"))
|
|
95
|
+
start = (page - 1) * page_size
|
|
96
|
+
end = start + page_size
|
|
97
|
+
|
|
98
|
+
list_display = list(model_admin.get_list_display(request))
|
|
99
|
+
columns = _columns_payload(model_admin, list_display)
|
|
100
|
+
|
|
101
|
+
results = [_row_for(obj, model_admin, list_display, request) for obj in queryset[start:end]]
|
|
102
|
+
|
|
103
|
+
body: dict[str, Any] = {
|
|
104
|
+
"app_label": model._meta.app_label,
|
|
105
|
+
"model_name": model._meta.model_name,
|
|
106
|
+
"permissions": model_permissions(model_admin, request),
|
|
107
|
+
"columns": columns,
|
|
108
|
+
"search_fields": list(model_admin.search_fields or ()),
|
|
109
|
+
"page": page,
|
|
110
|
+
"page_size": page_size,
|
|
111
|
+
"total": total,
|
|
112
|
+
"results": results,
|
|
113
|
+
}
|
|
114
|
+
response = JsonResponse(body, status=200)
|
|
115
|
+
# No-store: per-user, permission-gated payload must never be
|
|
116
|
+
# cached by intermediate proxies or the browser. Extends
|
|
117
|
+
# ACCEPTANCE.md §4.6 S-30 (defined for 4xx) to 200 responses.
|
|
118
|
+
response["Cache-Control"] = "no-store"
|
|
119
|
+
return response
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _clamp_page(raw: str | None) -> int:
|
|
123
|
+
"""Parse ``?page=`` into a positive integer, defaulting to 1.
|
|
124
|
+
|
|
125
|
+
Garbage input (non-integer, negative, missing) returns 1. The
|
|
126
|
+
endpoint never raises on a bad query string — that would let a
|
|
127
|
+
crawler send "?page=abc" to trigger a 500.
|
|
128
|
+
"""
|
|
129
|
+
try:
|
|
130
|
+
n = int(raw) if raw is not None else 1
|
|
131
|
+
except (TypeError, ValueError):
|
|
132
|
+
return 1
|
|
133
|
+
return max(1, n)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _clamp_page_size(raw: str | None) -> int:
|
|
137
|
+
"""Parse ``?page_size=`` and clamp to ``[1, conf.MAX_PAGE_SIZE]``.
|
|
138
|
+
|
|
139
|
+
The clamp is a denial-of-service guard: without an upper bound a
|
|
140
|
+
client could pass ``?page_size=10_000_000`` and force the
|
|
141
|
+
database to materialise ten million rows.
|
|
142
|
+
"""
|
|
143
|
+
default = int(conf.DEFAULT_PAGE_SIZE)
|
|
144
|
+
maximum = int(conf.MAX_PAGE_SIZE)
|
|
145
|
+
try:
|
|
146
|
+
n = int(raw) if raw is not None else default
|
|
147
|
+
except (TypeError, ValueError):
|
|
148
|
+
n = default
|
|
149
|
+
if n < 1:
|
|
150
|
+
return default
|
|
151
|
+
return min(n, maximum)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _apply_ordering(
|
|
155
|
+
queryset: QuerySet,
|
|
156
|
+
model_admin: ModelAdmin,
|
|
157
|
+
request: HttpRequest,
|
|
158
|
+
) -> QuerySet:
|
|
159
|
+
"""Apply ``?ordering=`` if every token is in the admin's allowed set.
|
|
160
|
+
|
|
161
|
+
Unknown tokens are silently dropped (per contract §7 and §3.4 C-5).
|
|
162
|
+
"""
|
|
163
|
+
raw = request.GET.get("ordering", "")
|
|
164
|
+
if not raw:
|
|
165
|
+
return queryset
|
|
166
|
+
allowed = _allowed_ordering(model_admin, request)
|
|
167
|
+
tokens = []
|
|
168
|
+
for token in raw.split(","):
|
|
169
|
+
token = token.strip()
|
|
170
|
+
if not token:
|
|
171
|
+
continue
|
|
172
|
+
bare = token.lstrip("-")
|
|
173
|
+
if bare in allowed:
|
|
174
|
+
tokens.append(token)
|
|
175
|
+
if not tokens:
|
|
176
|
+
return queryset
|
|
177
|
+
return queryset.order_by(*tokens)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _allowed_ordering(model_admin: ModelAdmin, request: HttpRequest) -> set[str]:
|
|
181
|
+
"""Return the field-name set the admin allows for ordering.
|
|
182
|
+
|
|
183
|
+
``ModelAdmin.get_sortable_by(request)`` (Django ≥ 2.1) is the
|
|
184
|
+
canonical source; falls back to ``list_display`` if not overridden.
|
|
185
|
+
"""
|
|
186
|
+
get_sortable_by = getattr(model_admin, "get_sortable_by", None)
|
|
187
|
+
if callable(get_sortable_by):
|
|
188
|
+
return set(get_sortable_by(request) or ())
|
|
189
|
+
return set(model_admin.get_list_display(request) or ())
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _columns_payload(
|
|
193
|
+
model_admin: ModelAdmin,
|
|
194
|
+
list_display: list[str],
|
|
195
|
+
) -> list[dict[str, Any]]:
|
|
196
|
+
"""Build the ``columns[]`` payload for the list response.
|
|
197
|
+
|
|
198
|
+
Each entry has ``{name, label, sortable}``. Labels resolve through
|
|
199
|
+
Django's ``label_for_field`` so admin-customised labels (verbose
|
|
200
|
+
name, ``short_description``, etc.) are honored. The ``except``
|
|
201
|
+
fallback to the bare name is defensive — corrupt admin
|
|
202
|
+
registrations should never 500 the endpoint.
|
|
203
|
+
"""
|
|
204
|
+
sortable = set(getattr(model_admin, "get_sortable_by", lambda r: ())(None) or ())
|
|
205
|
+
payload = []
|
|
206
|
+
for name in list_display:
|
|
207
|
+
try:
|
|
208
|
+
label = label_for_field(name, model_admin.model, model_admin)
|
|
209
|
+
except Exception: # pragma: no cover — defensive
|
|
210
|
+
label = name
|
|
211
|
+
payload.append(
|
|
212
|
+
{
|
|
213
|
+
"name": name,
|
|
214
|
+
"label": str(label),
|
|
215
|
+
"sortable": name in sortable,
|
|
216
|
+
}
|
|
217
|
+
)
|
|
218
|
+
return payload
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _row_for(
|
|
222
|
+
obj: Model,
|
|
223
|
+
model_admin: ModelAdmin,
|
|
224
|
+
list_display: list[str],
|
|
225
|
+
request: HttpRequest,
|
|
226
|
+
) -> dict[str, Any]:
|
|
227
|
+
"""Build one ``results[]`` entry for the list response.
|
|
228
|
+
|
|
229
|
+
Each row is ``{pk, label, fields: {name: serialized_value}}``.
|
|
230
|
+
Cell values go through ``lookup_field`` (so admin
|
|
231
|
+
``@admin.display`` callables resolve correctly), then through
|
|
232
|
+
the conservative serializer with ``str()`` fallback. The except
|
|
233
|
+
branch is intentional — a misbehaving ``list_display`` callable
|
|
234
|
+
must not break the whole list response (graceful degrade).
|
|
235
|
+
"""
|
|
236
|
+
fields: dict[str, Any] = {}
|
|
237
|
+
for name in list_display:
|
|
238
|
+
try:
|
|
239
|
+
_f, _attr, value = lookup_field(name, obj, model_admin)
|
|
240
|
+
except Exception: # pragma: no cover — defensive
|
|
241
|
+
value = ""
|
|
242
|
+
fields[name] = _serialize_list_value(obj, name, value)
|
|
243
|
+
return {"pk": obj.pk, "label": label_for(obj), "fields": fields}
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _serialize_list_value(obj: Model, name: str, value: Any) -> Any:
|
|
247
|
+
"""Serialize a single ``list_display`` cell.
|
|
248
|
+
|
|
249
|
+
FK fields go through the FK envelope (``{"id", "label"}``);
|
|
250
|
+
everything else goes through the conservative serializer with
|
|
251
|
+
``str()`` fallback. Callable list_display entries (e.g.
|
|
252
|
+
``@admin.display``) have already been resolved to a plain value
|
|
253
|
+
by ``lookup_field``.
|
|
254
|
+
"""
|
|
255
|
+
model_field = _safe_get_field(obj, name)
|
|
256
|
+
if isinstance(model_field, ForeignKey):
|
|
257
|
+
return serialize_fk_value(value)
|
|
258
|
+
return serialize_value(value)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _safe_get_field(obj: Model, name: str):
|
|
262
|
+
"""Return ``obj._meta.get_field(name)`` or ``None`` if not a real field.
|
|
263
|
+
|
|
264
|
+
``list_display`` may contain method names or ``@admin.display``
|
|
265
|
+
properties; those have no model field. The caller (FK detection)
|
|
266
|
+
only cares about the case where a real field exists.
|
|
267
|
+
"""
|
|
268
|
+
try:
|
|
269
|
+
return obj._meta.get_field(name)
|
|
270
|
+
except Exception:
|
|
271
|
+
return None
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""GET /api/v1/registry/ — apps and models the user may see.
|
|
2
|
+
|
|
3
|
+
Wire contract: ``docs/api-contract.md`` §2.
|
|
4
|
+
|
|
5
|
+
Implementation rules followed (`SECURITY.md` §3):
|
|
6
|
+
|
|
7
|
+
- Rule 1: Default permission gate is staff + ``AdminSite.has_permission``.
|
|
8
|
+
- Rule 3: Models come exclusively from the configured admin site's
|
|
9
|
+
``_registry`` — we never look at the global app registry.
|
|
10
|
+
- Rule 5: ``ModelAdmin.has_module_permission`` and ``has_view_permission``
|
|
11
|
+
decide visibility; we never invent our own gate.
|
|
12
|
+
- Rule 10: No ``Model.objects.all()`` is ever called from this view —
|
|
13
|
+
it doesn't read any model data at all.
|
|
14
|
+
- Rule 12: Failures return 403 with an opaque body, never 404.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from django.http import HttpRequest
|
|
20
|
+
from django.http import HttpResponse
|
|
21
|
+
from django.http import JsonResponse
|
|
22
|
+
from django.views.generic import View
|
|
23
|
+
|
|
24
|
+
from django_admin_react.api.permissions import forbidden_response
|
|
25
|
+
from django_admin_react.api.permissions import is_admin_user
|
|
26
|
+
from django_admin_react.api.registry import build_registry_payload
|
|
27
|
+
from django_admin_react.api.registry import get_admin_site
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class RegistryView(View):
|
|
31
|
+
"""``GET /api/v1/registry/`` — registry of visible apps and models."""
|
|
32
|
+
|
|
33
|
+
http_method_names = ["get"]
|
|
34
|
+
|
|
35
|
+
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: # noqa: ARG002
|
|
36
|
+
"""Return the registry payload (contract §2).
|
|
37
|
+
|
|
38
|
+
Hard gate: ``is_admin_user(request)`` (rule 1) → 403 envelope
|
|
39
|
+
if the request isn't authenticated active staff. Otherwise
|
|
40
|
+
builds the payload via :func:`build_registry_payload` and
|
|
41
|
+
attaches ``Cache-Control: no-store`` so the per-user payload
|
|
42
|
+
is never cached by intermediate proxies.
|
|
43
|
+
"""
|
|
44
|
+
admin_site = get_admin_site()
|
|
45
|
+
if not is_admin_user(request, admin_site=admin_site):
|
|
46
|
+
return forbidden_response()
|
|
47
|
+
response = JsonResponse(build_registry_payload(admin_site, request), status=200)
|
|
48
|
+
# Never let an intermediate proxy or browser cache cross-user
|
|
49
|
+
# data (extends ACCEPTANCE.md §4.6 S-30 to 200 responses).
|
|
50
|
+
response["Cache-Control"] = "no-store"
|
|
51
|
+
return response
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""``PATCH /api/v1/<app>/<model>/<pk>/`` — partial update endpoint.
|
|
2
|
+
|
|
3
|
+
Wire contract: ``docs/api-contract.md`` §5.2.
|
|
4
|
+
|
|
5
|
+
Hard rules (`SECURITY.md` §3, `ACCEPTANCE.md` §3.1):
|
|
6
|
+
|
|
7
|
+
- Rule 3: Model resolved through ``admin.site._registry`` (B-7).
|
|
8
|
+
- Rule 5: ``has_change_permission(request, obj)`` per-object gate.
|
|
9
|
+
- Rule 6: Writes go through ``ModelAdmin.get_form()`` then
|
|
10
|
+
``save_model(..., change=True)`` (B-3).
|
|
11
|
+
- Rule 10: Queryset starts at ``ModelAdmin.get_queryset(request)`` —
|
|
12
|
+
never ``Model.objects.all()`` (B-2).
|
|
13
|
+
- Rule 12: Writes to ``readonly`` / ``exclude`` keys → 400 (S-31, B-3).
|
|
14
|
+
- CSRF: No ``@csrf_exempt`` — Django's middleware enforces.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from django.db import transaction
|
|
22
|
+
from django.http import HttpRequest
|
|
23
|
+
from django.http import HttpResponse
|
|
24
|
+
from django.http import JsonResponse
|
|
25
|
+
from django.views.generic import View
|
|
26
|
+
|
|
27
|
+
from django_admin_react.api.permissions import forbidden_response
|
|
28
|
+
from django_admin_react.api.permissions import is_admin_user
|
|
29
|
+
from django_admin_react.api.registry import get_admin_site
|
|
30
|
+
from django_admin_react.api.registry import resolve_model
|
|
31
|
+
from django_admin_react.api.views.detail import _build_payload
|
|
32
|
+
from django_admin_react.api.writes import form_errors_to_envelope
|
|
33
|
+
from django_admin_react.api.writes import load_object_or_none
|
|
34
|
+
from django_admin_react.api.writes import merged_initial_for_update
|
|
35
|
+
from django_admin_react.api.writes import not_found_response
|
|
36
|
+
from django_admin_react.api.writes import parse_json_body
|
|
37
|
+
from django_admin_react.api.writes import readonly_or_excluded_names
|
|
38
|
+
from django_admin_react.api.writes import reject_forbidden_keys
|
|
39
|
+
from django_admin_react.api.writes import validation_failed
|
|
40
|
+
from django_admin_react.api.writes import writable_field_names
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class UpdateView(View):
|
|
44
|
+
"""``PATCH /api/v1/<app_label>/<model_name>/<pk>/``."""
|
|
45
|
+
|
|
46
|
+
http_method_names = ["patch"]
|
|
47
|
+
|
|
48
|
+
def patch(
|
|
49
|
+
self,
|
|
50
|
+
request: HttpRequest,
|
|
51
|
+
app_label: str,
|
|
52
|
+
model_name: str,
|
|
53
|
+
pk: str,
|
|
54
|
+
*args: Any,
|
|
55
|
+
**kwargs: Any,
|
|
56
|
+
) -> HttpResponse:
|
|
57
|
+
"""Partially update an instance (contract §5.2).
|
|
58
|
+
|
|
59
|
+
PATCH semantics: any field the payload omits keeps its
|
|
60
|
+
current value. The implementation builds form ``initial``
|
|
61
|
+
data by overlaying the payload on the instance's current
|
|
62
|
+
values, then runs ``ModelAdmin.get_form()`` exactly like the
|
|
63
|
+
Django admin change view.
|
|
64
|
+
|
|
65
|
+
Gates: ``is_admin_user`` → ``resolve_model`` →
|
|
66
|
+
``load_object_or_none`` (uses the admin's queryset, never
|
|
67
|
+
``Model.objects.all()``) → ``has_change_permission(request,
|
|
68
|
+
obj)`` per-object gate (rule 5).
|
|
69
|
+
|
|
70
|
+
Same payload-shape validation as create (unknown / readonly /
|
|
71
|
+
excluded / sensitive keys → 400). Write path goes through
|
|
72
|
+
``form.save(commit=False)`` →
|
|
73
|
+
``model_admin.save_model(..., change=True)`` (rule 6 / B-3),
|
|
74
|
+
wrapped in ``transaction.atomic()``.
|
|
75
|
+
"""
|
|
76
|
+
admin_site = get_admin_site()
|
|
77
|
+
if not is_admin_user(request, admin_site=admin_site):
|
|
78
|
+
return forbidden_response()
|
|
79
|
+
|
|
80
|
+
resolved = resolve_model(admin_site, request, app_label, model_name)
|
|
81
|
+
if resolved is None:
|
|
82
|
+
return not_found_response()
|
|
83
|
+
model, model_admin = resolved
|
|
84
|
+
|
|
85
|
+
obj = load_object_or_none(model, model_admin, request, pk)
|
|
86
|
+
if obj is None:
|
|
87
|
+
return not_found_response()
|
|
88
|
+
|
|
89
|
+
if not model_admin.has_change_permission(request, obj):
|
|
90
|
+
return forbidden_response()
|
|
91
|
+
|
|
92
|
+
parsed = parse_json_body(request)
|
|
93
|
+
if isinstance(parsed, HttpResponse):
|
|
94
|
+
return parsed
|
|
95
|
+
payload: dict[str, Any] = parsed
|
|
96
|
+
|
|
97
|
+
writable = writable_field_names(model, model_admin, request, obj)
|
|
98
|
+
forbidden = readonly_or_excluded_names(model_admin, request, obj)
|
|
99
|
+
rejection = reject_forbidden_keys(payload, writable, forbidden)
|
|
100
|
+
if rejection is not None:
|
|
101
|
+
return rejection
|
|
102
|
+
|
|
103
|
+
form = model_admin.get_form(request, obj=obj)(
|
|
104
|
+
data=merged_initial_for_update(obj, writable, payload, model),
|
|
105
|
+
files=None,
|
|
106
|
+
instance=obj,
|
|
107
|
+
)
|
|
108
|
+
if not form.is_valid():
|
|
109
|
+
return validation_failed(form_errors_to_envelope(form))
|
|
110
|
+
|
|
111
|
+
with transaction.atomic():
|
|
112
|
+
instance = form.save(commit=False)
|
|
113
|
+
model_admin.save_model(request, instance, form, change=True)
|
|
114
|
+
form.save_m2m()
|
|
115
|
+
|
|
116
|
+
response = JsonResponse(
|
|
117
|
+
_build_payload(model, model_admin, instance, request),
|
|
118
|
+
status=200,
|
|
119
|
+
)
|
|
120
|
+
response["Cache-Control"] = "no-store"
|
|
121
|
+
return response
|