django-admin-react 0.2.0a6__tar.gz → 0.2.0a7__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.0a6 → django_admin_react-0.2.0a7}/PKG-INFO +51 -1
- {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a7}/README.md +50 -0
- {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a7}/django_admin_react/api/filters.py +56 -2
- {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a7}/django_admin_react/api/registry.py +6 -0
- {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a7}/django_admin_react/api/views/actions.py +12 -2
- {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a7}/django_admin_react/api/views/create_form.py +91 -1
- {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a7}/django_admin_react/api/views/detail.py +20 -2
- {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a7}/django_admin_react/api/views/list.py +41 -11
- {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a7}/django_admin_react/static/admin_react/.vite/manifest.json +2 -2
- django_admin_react-0.2.0a7/django_admin_react/static/admin_react/assets/index-BK8mlqvA.js +8 -0
- django_admin_react-0.2.0a7/django_admin_react/static/admin_react/assets/index-BVqO3W_r.css +1 -0
- {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a7}/django_admin_react/static/admin_react/index.html +2 -2
- {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a7}/pyproject.toml +1 -1
- django_admin_react-0.2.0a6/django_admin_react/static/admin_react/assets/index-Beowap5h.css +0 -1
- django_admin_react-0.2.0a6/django_admin_react/static/admin_react/assets/index-CgWOpEY8.js +0 -8
- {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a7}/LICENSE +0 -0
- {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a7}/django_admin_react/README.md +0 -0
- {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a7}/django_admin_react/__init__.py +0 -0
- {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a7}/django_admin_react/api/README.md +0 -0
- {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a7}/django_admin_react/api/__init__.py +0 -0
- {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a7}/django_admin_react/api/dates.py +0 -0
- {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a7}/django_admin_react/api/inlines.py +0 -0
- {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a7}/django_admin_react/api/inlines_write.py +0 -0
- {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a7}/django_admin_react/api/panels.py +0 -0
- {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a7}/django_admin_react/api/permissions.py +0 -0
- {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a7}/django_admin_react/api/serializers.py +0 -0
- {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a7}/django_admin_react/api/urls.py +0 -0
- {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a7}/django_admin_react/api/views/README.md +0 -0
- {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a7}/django_admin_react/api/views/__init__.py +0 -0
- {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a7}/django_admin_react/api/views/auth.py +0 -0
- {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a7}/django_admin_react/api/views/autocomplete.py +0 -0
- {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a7}/django_admin_react/api/views/bulk.py +0 -0
- {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a7}/django_admin_react/api/views/create.py +0 -0
- {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a7}/django_admin_react/api/views/delete_preview.py +0 -0
- {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a7}/django_admin_react/api/views/destroy.py +0 -0
- {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a7}/django_admin_react/api/views/history.py +0 -0
- {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a7}/django_admin_react/api/views/password.py +0 -0
- {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a7}/django_admin_react/api/views/registry.py +0 -0
- {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a7}/django_admin_react/api/views/schema.py +0 -0
- {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a7}/django_admin_react/api/views/update.py +0 -0
- {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a7}/django_admin_react/api/writes.py +0 -0
- {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a7}/django_admin_react/apps.py +0 -0
- {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a7}/django_admin_react/audit.py +0 -0
- {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a7}/django_admin_react/conf.py +0 -0
- {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a7}/django_admin_react/pwa.py +0 -0
- {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a7}/django_admin_react/templates/README.md +0 -0
- {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a7}/django_admin_react/templates/admin_react/README.md +0 -0
- {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a7}/django_admin_react/templates/admin_react/index.html +0 -0
- {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a7}/django_admin_react/templates/admin_react/login.html +0 -0
- {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a7}/django_admin_react/templates/admin_react/sw.js +0 -0
- {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a7}/django_admin_react/urls.py +0 -0
- {django_admin_react-0.2.0a6 → django_admin_react-0.2.0a7}/django_admin_react/views.py +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.0a7
|
|
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
|
|
@@ -152,6 +152,10 @@ DJANGO_ADMIN_REACT = {
|
|
|
152
152
|
"BRAND_LOGO_URL": None, # str | None — used as the favicon and
|
|
153
153
|
# the sidebar logo. Absolute URL or a
|
|
154
154
|
# path under your STATIC_URL.
|
|
155
|
+
"PRIMARY_COLOR": "#2563eb", # accent for primary buttons, links, and
|
|
156
|
+
# active states. Hex only (validated);
|
|
157
|
+
# injected as the --dar-primary CSS var, so
|
|
158
|
+
# rebranding needs no React rebuild.
|
|
155
159
|
}
|
|
156
160
|
```
|
|
157
161
|
|
|
@@ -193,6 +197,52 @@ brand. No flash of the package's defaults.
|
|
|
193
197
|
`AUTH_USER_MODEL`, custom `AUTHENTICATION_BACKENDS`, and custom
|
|
194
198
|
`AdminSite.has_permission`.
|
|
195
199
|
|
|
200
|
+
### Production: static files (and media for file uploads)
|
|
201
|
+
|
|
202
|
+
The wheel ships the pre-built bundle under the package's `static/` and
|
|
203
|
+
serves it through `{% static %}`. With `DEBUG = True`, Django's
|
|
204
|
+
staticfiles app serves it automatically — nothing to do. **In
|
|
205
|
+
production** you collect + serve static files like any Django app:
|
|
206
|
+
|
|
207
|
+
```python
|
|
208
|
+
# settings.py
|
|
209
|
+
STATIC_URL = "/static/"
|
|
210
|
+
STATIC_ROOT = BASE_DIR / "staticfiles" # where collectstatic gathers files
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
```bash
|
|
214
|
+
python manage.py collectstatic --no-input
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
Then serve `STATIC_ROOT` from your web server / CDN — or let
|
|
218
|
+
[WhiteNoise](https://whitenoise.readthedocs.io/) do it:
|
|
219
|
+
|
|
220
|
+
```python
|
|
221
|
+
MIDDLEWARE = [
|
|
222
|
+
"django.middleware.security.SecurityMiddleware",
|
|
223
|
+
"whitenoise.middleware.WhiteNoiseMiddleware", # right after SecurityMiddleware
|
|
224
|
+
# ...
|
|
225
|
+
]
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
> If the SPA shell loads but its JS/CSS 404 (blank page, console errors),
|
|
229
|
+
> this `collectstatic` step is what's missing.
|
|
230
|
+
|
|
231
|
+
**File / image fields.** Editing `FileField` / `ImageField` needs
|
|
232
|
+
Django's media settings:
|
|
233
|
+
|
|
234
|
+
```python
|
|
235
|
+
# settings.py
|
|
236
|
+
MEDIA_URL = "/media/"
|
|
237
|
+
MEDIA_ROOT = BASE_DIR / "media"
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
Uploads go through your configured file storage
|
|
241
|
+
(`STORAGES["default"]` / `DEFAULT_FILE_STORAGE`); in production serve
|
|
242
|
+
`MEDIA_ROOT` from your web server or object storage as usual.
|
|
243
|
+
|
|
244
|
+
> ⚠️ **Serving user-uploaded media has security implications** (access-gating, stored-file XSS). See [`SECURITY.md` §9](SECURITY.md) before exposing `MEDIA_URL` in production — `FileField`/`ImageField` are writable.
|
|
245
|
+
|
|
196
246
|
### Running side-by-side with the legacy admin
|
|
197
247
|
|
|
198
248
|
A common rollout: keep `/admin/` on the legacy HTML admin, mount the
|
|
@@ -121,6 +121,10 @@ DJANGO_ADMIN_REACT = {
|
|
|
121
121
|
"BRAND_LOGO_URL": None, # str | None — used as the favicon and
|
|
122
122
|
# the sidebar logo. Absolute URL or a
|
|
123
123
|
# path under your STATIC_URL.
|
|
124
|
+
"PRIMARY_COLOR": "#2563eb", # accent for primary buttons, links, and
|
|
125
|
+
# active states. Hex only (validated);
|
|
126
|
+
# injected as the --dar-primary CSS var, so
|
|
127
|
+
# rebranding needs no React rebuild.
|
|
124
128
|
}
|
|
125
129
|
```
|
|
126
130
|
|
|
@@ -162,6 +166,52 @@ brand. No flash of the package's defaults.
|
|
|
162
166
|
`AUTH_USER_MODEL`, custom `AUTHENTICATION_BACKENDS`, and custom
|
|
163
167
|
`AdminSite.has_permission`.
|
|
164
168
|
|
|
169
|
+
### Production: static files (and media for file uploads)
|
|
170
|
+
|
|
171
|
+
The wheel ships the pre-built bundle under the package's `static/` and
|
|
172
|
+
serves it through `{% static %}`. With `DEBUG = True`, Django's
|
|
173
|
+
staticfiles app serves it automatically — nothing to do. **In
|
|
174
|
+
production** you collect + serve static files like any Django app:
|
|
175
|
+
|
|
176
|
+
```python
|
|
177
|
+
# settings.py
|
|
178
|
+
STATIC_URL = "/static/"
|
|
179
|
+
STATIC_ROOT = BASE_DIR / "staticfiles" # where collectstatic gathers files
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
```bash
|
|
183
|
+
python manage.py collectstatic --no-input
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
Then serve `STATIC_ROOT` from your web server / CDN — or let
|
|
187
|
+
[WhiteNoise](https://whitenoise.readthedocs.io/) do it:
|
|
188
|
+
|
|
189
|
+
```python
|
|
190
|
+
MIDDLEWARE = [
|
|
191
|
+
"django.middleware.security.SecurityMiddleware",
|
|
192
|
+
"whitenoise.middleware.WhiteNoiseMiddleware", # right after SecurityMiddleware
|
|
193
|
+
# ...
|
|
194
|
+
]
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
> If the SPA shell loads but its JS/CSS 404 (blank page, console errors),
|
|
198
|
+
> this `collectstatic` step is what's missing.
|
|
199
|
+
|
|
200
|
+
**File / image fields.** Editing `FileField` / `ImageField` needs
|
|
201
|
+
Django's media settings:
|
|
202
|
+
|
|
203
|
+
```python
|
|
204
|
+
# settings.py
|
|
205
|
+
MEDIA_URL = "/media/"
|
|
206
|
+
MEDIA_ROOT = BASE_DIR / "media"
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
Uploads go through your configured file storage
|
|
210
|
+
(`STORAGES["default"]` / `DEFAULT_FILE_STORAGE`); in production serve
|
|
211
|
+
`MEDIA_ROOT` from your web server or object storage as usual.
|
|
212
|
+
|
|
213
|
+
> ⚠️ **Serving user-uploaded media has security implications** (access-gating, stored-file XSS). See [`SECURITY.md` §9](SECURITY.md) before exposing `MEDIA_URL` in production — `FileField`/`ImageField` are writable.
|
|
214
|
+
|
|
165
215
|
### Running side-by-side with the legacy admin
|
|
166
216
|
|
|
167
217
|
A common rollout: keep `/admin/` on the legacy HTML admin, mount the
|
|
@@ -92,6 +92,38 @@ def _safe_get_field(model: type[Model], name: str) -> Field | None:
|
|
|
92
92
|
return field if isinstance(field, Field) else None
|
|
93
93
|
|
|
94
94
|
|
|
95
|
+
def _resolve_field_path(model: type[Model], path: str) -> Field | None:
|
|
96
|
+
"""Resolve a ``list_filter`` entry to its leaf model ``Field``.
|
|
97
|
+
|
|
98
|
+
Handles a plain field name (``"status"``) and a **related-field path**
|
|
99
|
+
that spans relations (``"author__is_active"`` / ``"order__customer__country"``):
|
|
100
|
+
each non-final segment must be a relation we can traverse, and the
|
|
101
|
+
final segment is the leaf field whose *type* drives the descriptor and
|
|
102
|
+
whose value the ORM filters on (Django applies ``filter(path=value)``
|
|
103
|
+
natively). Transform lookups (``__year`` / ``__gte`` / ``__icontains``)
|
|
104
|
+
are not fields and resolve to ``None`` — a separate follow-up (#440).
|
|
105
|
+
Reverse / generic relations collapse to ``None``, like ``_safe_get_field``.
|
|
106
|
+
"""
|
|
107
|
+
parts = path.split("__")
|
|
108
|
+
current: type[Model] = model
|
|
109
|
+
field: Field | None = None
|
|
110
|
+
for index, part in enumerate(parts):
|
|
111
|
+
try:
|
|
112
|
+
candidate = current._meta.get_field(part)
|
|
113
|
+
except Exception:
|
|
114
|
+
return None
|
|
115
|
+
if not isinstance(candidate, Field):
|
|
116
|
+
return None
|
|
117
|
+
field = candidate
|
|
118
|
+
if index < len(parts) - 1:
|
|
119
|
+
# Non-final segment must be a relation we can step into.
|
|
120
|
+
related = getattr(candidate, "related_model", None)
|
|
121
|
+
if related is None or isinstance(related, str):
|
|
122
|
+
return None
|
|
123
|
+
current = related
|
|
124
|
+
return field
|
|
125
|
+
|
|
126
|
+
|
|
95
127
|
def _spec_for_boolean(field_name: str, field: Any) -> dict[str, Any]:
|
|
96
128
|
return {
|
|
97
129
|
"name": field_name,
|
|
@@ -169,6 +201,15 @@ def _spec_for_fk(
|
|
|
169
201
|
payload["choices"] = [
|
|
170
202
|
{"value": obj.pk, "label": label_for(obj)} for obj in base_qs[:_FK_FILTER_MAX_OPTIONS]
|
|
171
203
|
]
|
|
204
|
+
elif admin_site is not None:
|
|
205
|
+
# High-cardinality target (#282): don't inline; hint the SPA to use
|
|
206
|
+
# the autocomplete endpoint for this filter — but only when the
|
|
207
|
+
# target admin declares ``search_fields`` (autocomplete 400s
|
|
208
|
+
# otherwise). The endpoint is already staff-gated and runs the
|
|
209
|
+
# target's own ``get_search_results``; this is purely a UI hint.
|
|
210
|
+
target_admin = admin_site._registry.get(related)
|
|
211
|
+
if target_admin is not None and getattr(target_admin, "search_fields", None):
|
|
212
|
+
payload["autocomplete"] = True
|
|
172
213
|
return payload
|
|
173
214
|
|
|
174
215
|
|
|
@@ -267,9 +308,17 @@ def filters_payload(
|
|
|
267
308
|
if is_sensitive_field_name(field_name):
|
|
268
309
|
continue
|
|
269
310
|
|
|
270
|
-
field
|
|
311
|
+
# Resolve a plain field OR a related-field path (#440). The
|
|
312
|
+
# descriptor `name` stays the full path so the SPA round-trips
|
|
313
|
+
# `?<path>=<value>` and the ORM filters natively.
|
|
314
|
+
field = _resolve_field_path(model, field_name)
|
|
271
315
|
if field is None:
|
|
272
316
|
continue
|
|
317
|
+
# Defense-in-depth: a path can end in a sensitive leaf
|
|
318
|
+
# (`author__password`) even when the path string itself didn't trip
|
|
319
|
+
# the denylist — drop it.
|
|
320
|
+
if is_sensitive_field_name(field.name):
|
|
321
|
+
continue
|
|
273
322
|
if isinstance(field, BooleanField):
|
|
274
323
|
out.append(_spec_for_boolean(field_name, field))
|
|
275
324
|
elif isinstance(field, ForeignKey):
|
|
@@ -327,9 +376,14 @@ def apply_filters(queryset: QuerySet, model_admin: ModelAdmin, request: HttpRequ
|
|
|
327
376
|
if raw_value is None or raw_value == "":
|
|
328
377
|
continue
|
|
329
378
|
|
|
330
|
-
field
|
|
379
|
+
# Resolve a plain field OR a related-field path (#440); the leaf
|
|
380
|
+
# field's type picks the coercion below, while the full path is the
|
|
381
|
+
# lookup the ORM applies (`filter(author__is_active=True)`).
|
|
382
|
+
field = _resolve_field_path(model, field_name)
|
|
331
383
|
if field is None:
|
|
332
384
|
continue
|
|
385
|
+
if is_sensitive_field_name(field.name):
|
|
386
|
+
continue
|
|
333
387
|
|
|
334
388
|
try:
|
|
335
389
|
if isinstance(field, BooleanField):
|
{django_admin_react-0.2.0a6 → django_admin_react-0.2.0a7}/django_admin_react/api/registry.py
RENAMED
|
@@ -319,6 +319,10 @@ def save_options(
|
|
|
319
319
|
flag (default True): after a "Save as new", whether the SPA
|
|
320
320
|
lands on the new object's change view (True) or the changelist
|
|
321
321
|
(False).
|
|
322
|
+
- ``save_on_top`` — the raw ``ModelAdmin.save_on_top`` flag (default
|
|
323
|
+
False): when True, the SPA mirrors the save-button row at the top
|
|
324
|
+
of the form too, matching Django's change-form layout (#251).
|
|
325
|
+
Purely presentational — button visibility is unchanged.
|
|
322
326
|
|
|
323
327
|
``has_editable_inline_admin_formsets`` is **not** factored in here
|
|
324
328
|
(the package's inline write-half is tracked under #54). Until that
|
|
@@ -330,6 +334,7 @@ def save_options(
|
|
|
330
334
|
is_add = not is_change
|
|
331
335
|
save_as = bool(getattr(model_admin, "save_as", False))
|
|
332
336
|
save_as_continue = bool(getattr(model_admin, "save_as_continue", True))
|
|
337
|
+
save_on_top = bool(getattr(model_admin, "save_on_top", False))
|
|
333
338
|
|
|
334
339
|
has_add = bool(model_admin.has_add_permission(request))
|
|
335
340
|
has_change = bool(model_admin.has_change_permission(request, obj))
|
|
@@ -351,6 +356,7 @@ def save_options(
|
|
|
351
356
|
"show_save_as_new": show_save_as_new,
|
|
352
357
|
"save_as": save_as,
|
|
353
358
|
"save_as_continue": save_as_continue,
|
|
359
|
+
"save_on_top": save_on_top,
|
|
354
360
|
}
|
|
355
361
|
|
|
356
362
|
|
{django_admin_react-0.2.0a6 → django_admin_react-0.2.0a7}/django_admin_react/api/views/actions.py
RENAMED
|
@@ -32,6 +32,7 @@ from __future__ import annotations
|
|
|
32
32
|
from typing import Any
|
|
33
33
|
|
|
34
34
|
from django.contrib.admin.utils import model_format_dict
|
|
35
|
+
from django.contrib.messages import get_messages
|
|
35
36
|
from django.db import transaction
|
|
36
37
|
from django.http import HttpRequest
|
|
37
38
|
from django.http import HttpResponse
|
|
@@ -131,17 +132,26 @@ class ActionView(View):
|
|
|
131
132
|
with transaction.atomic():
|
|
132
133
|
result = action_callable(model_admin, request, queryset)
|
|
133
134
|
|
|
135
|
+
# Surface any messages the action queued via
|
|
136
|
+
# ``ModelAdmin.message_user`` (#442) so the SPA can toast them —
|
|
137
|
+
# iterating ``get_messages`` consumes them, so they don't also leak
|
|
138
|
+
# into the session for the next page render. ``level_tag`` is
|
|
139
|
+
# Django's "success" / "info" / "warning" / "error" / "debug".
|
|
140
|
+
messages = [
|
|
141
|
+
{"level": m.level_tag or "info", "message": str(m)} for m in get_messages(request)
|
|
142
|
+
]
|
|
143
|
+
|
|
134
144
|
# Django admin's action contract: the callable may return an
|
|
135
145
|
# ``HttpResponse`` (typically a redirect to a confirmation
|
|
136
146
|
# page) — we surface that as a JSON envelope so the SPA can
|
|
137
147
|
# follow it without parsing HTML.
|
|
138
148
|
if isinstance(result, HttpResponse):
|
|
139
149
|
body: dict[str, Any] = {"redirect": result["Location"]} if "Location" in result else {}
|
|
140
|
-
body.update({"executed": True, "action": action_name})
|
|
150
|
+
body.update({"executed": True, "action": action_name, "messages": messages})
|
|
141
151
|
response = JsonResponse(body, status=200)
|
|
142
152
|
else:
|
|
143
153
|
response = JsonResponse(
|
|
144
|
-
{"executed": True, "action": action_name, "pks": list(pks)},
|
|
154
|
+
{"executed": True, "action": action_name, "pks": list(pks), "messages": messages},
|
|
145
155
|
status=200,
|
|
146
156
|
)
|
|
147
157
|
response["Cache-Control"] = "no-store"
|
|
@@ -19,6 +19,11 @@ from __future__ import annotations
|
|
|
19
19
|
|
|
20
20
|
from typing import Any
|
|
21
21
|
|
|
22
|
+
from django.core.exceptions import ValidationError
|
|
23
|
+
from django.db.models import FileField
|
|
24
|
+
from django.db.models import ForeignKey
|
|
25
|
+
from django.db.models import ManyToManyField
|
|
26
|
+
from django.db.models import Model
|
|
22
27
|
from django.http import HttpRequest
|
|
23
28
|
from django.http import HttpResponse
|
|
24
29
|
from django.http import JsonResponse
|
|
@@ -30,6 +35,9 @@ from django_admin_react.api.registry import get_admin_site
|
|
|
30
35
|
from django_admin_react.api.registry import model_permissions
|
|
31
36
|
from django_admin_react.api.registry import resolve_model
|
|
32
37
|
from django_admin_react.api.registry import save_options
|
|
38
|
+
from django_admin_react.api.serializers import safe_get_field
|
|
39
|
+
from django_admin_react.api.serializers import serialize_fk_value
|
|
40
|
+
from django_admin_react.api.serializers import serialize_value
|
|
33
41
|
from django_admin_react.api.views.detail import _descriptor_for
|
|
34
42
|
from django_admin_react.api.views.detail import _fieldsets_payload
|
|
35
43
|
from django_admin_react.api.views.detail import _visible_field_names
|
|
@@ -69,10 +77,17 @@ class AddFormView(View):
|
|
|
69
77
|
|
|
70
78
|
visible_names = _visible_field_names(model_admin, request, None)
|
|
71
79
|
readonly = set(model_admin.get_readonly_fields(request, None) or ())
|
|
80
|
+
# Initial overlay (#444): Django's add view seeds the form with
|
|
81
|
+
# ``get_changeform_initial_data(request)`` — which, by default,
|
|
82
|
+
# reflects ``request.GET`` so a link like ``/add/?status=open``
|
|
83
|
+
# (and the "save and add another" prefill) lands pre-filled, and
|
|
84
|
+
# which a ModelAdmin may override. Build the form with that
|
|
85
|
+
# initial, exactly how ``_changeform_view`` does.
|
|
86
|
+
initial = _changeform_initial(model_admin, request)
|
|
72
87
|
# The ADD form — change=False, obj=None — exactly how Django's
|
|
73
88
|
# add view constructs it (``ModelAdmin._changeform_view`` with
|
|
74
89
|
# add=True passes change=False).
|
|
75
|
-
form = model_admin.get_form(request, obj=None, change=False)()
|
|
90
|
+
form = model_admin.get_form(request, obj=None, change=False)(initial=initial)
|
|
76
91
|
|
|
77
92
|
fields: dict[str, dict[str, Any]] = {}
|
|
78
93
|
for name in visible_names:
|
|
@@ -87,6 +102,14 @@ class AddFormView(View):
|
|
|
87
102
|
request=request,
|
|
88
103
|
)
|
|
89
104
|
|
|
105
|
+
# Overlay the initial values onto the descriptors. Done as a
|
|
106
|
+
# second pass (rather than mutating ``obj``) so the shared
|
|
107
|
+
# descriptor builder stays untouched and a bad initial can never
|
|
108
|
+
# 500 the form: each value is coerced through the add-form's own
|
|
109
|
+
# field, so FKs resolve against the admin-scoped ``ModelChoiceField``
|
|
110
|
+
# queryset (rule 2) and an invalid initial is simply ignored.
|
|
111
|
+
_overlay_initial(fields, model, form, initial, admin_site, request)
|
|
112
|
+
|
|
90
113
|
payload = {
|
|
91
114
|
"app_label": model._meta.app_label,
|
|
92
115
|
"model_name": model._meta.model_name,
|
|
@@ -110,6 +133,73 @@ class AddFormView(View):
|
|
|
110
133
|
return response
|
|
111
134
|
|
|
112
135
|
|
|
136
|
+
def _changeform_initial(model_admin: Any, request: HttpRequest) -> dict[str, Any]:
|
|
137
|
+
"""Return ``get_changeform_initial_data(request)`` as a safe dict (#444).
|
|
138
|
+
|
|
139
|
+
Django's default reads ``request.GET`` (so ``?field=value`` links
|
|
140
|
+
prefill) and an admin may override it to inject defaults. A buggy
|
|
141
|
+
override must not 500 the form, so a non-dict or a raised exception
|
|
142
|
+
degrades to "no prefill".
|
|
143
|
+
"""
|
|
144
|
+
try:
|
|
145
|
+
data = model_admin.get_changeform_initial_data(request)
|
|
146
|
+
except Exception: # pragma: no cover — admin author error
|
|
147
|
+
return {}
|
|
148
|
+
return data if isinstance(data, dict) else {}
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _overlay_initial(
|
|
152
|
+
fields: dict[str, dict[str, Any]],
|
|
153
|
+
model: type[Model],
|
|
154
|
+
form: Any,
|
|
155
|
+
initial: dict[str, Any],
|
|
156
|
+
admin_site: Any,
|
|
157
|
+
request: HttpRequest,
|
|
158
|
+
) -> None:
|
|
159
|
+
"""Overlay add-form initial values onto the field descriptors (#444).
|
|
160
|
+
|
|
161
|
+
Only fields that are both rendered (in ``fields``) and present in the
|
|
162
|
+
add form are touched — an initial for an excluded/sensitive field is
|
|
163
|
+
ignored, since that field isn't in the payload to begin with. Each
|
|
164
|
+
value is coerced through the form field's ``to_python`` so it matches
|
|
165
|
+
exactly what Django would render into the widget:
|
|
166
|
+
|
|
167
|
+
- FK → the form's ``ModelChoiceField.queryset`` resolves the pk to an
|
|
168
|
+
instance (admin-scoped, rule 2), serialized as the ``{id, label}``
|
|
169
|
+
envelope; an unknown/invalid pk raises and is ignored (no 500).
|
|
170
|
+
- scalar / choice / bool / date → coerced and re-serialized in place.
|
|
171
|
+
- M2M / File → skipped: neither is meaningfully settable on the
|
|
172
|
+
unsaved add instance, and GET-param prefill of them is not a thing
|
|
173
|
+
Django's add view does either.
|
|
174
|
+
|
|
175
|
+
Any coercion error leaves the field's default value untouched.
|
|
176
|
+
"""
|
|
177
|
+
for name, raw in initial.items():
|
|
178
|
+
descriptor = fields.get(name)
|
|
179
|
+
if descriptor is None:
|
|
180
|
+
continue
|
|
181
|
+
field = safe_get_field(model, name)
|
|
182
|
+
form_field = form.fields.get(name)
|
|
183
|
+
if field is None or form_field is None:
|
|
184
|
+
continue
|
|
185
|
+
if isinstance(field, ManyToManyField | FileField):
|
|
186
|
+
continue
|
|
187
|
+
# Coercion failures (a bad FK pk, an unparseable date) are the
|
|
188
|
+
# expected outcome of a hand-crafted prefill URL — narrow the
|
|
189
|
+
# catch to what ``to_python`` raises so a real bug still surfaces,
|
|
190
|
+
# and leave the field's default value in place.
|
|
191
|
+
try:
|
|
192
|
+
if isinstance(field, ForeignKey):
|
|
193
|
+
related = form_field.to_python(raw)
|
|
194
|
+
descriptor["value"] = serialize_fk_value(
|
|
195
|
+
related, admin_site=admin_site, request=request
|
|
196
|
+
)
|
|
197
|
+
else:
|
|
198
|
+
descriptor["value"] = serialize_value(form_field.to_python(raw), field=field)
|
|
199
|
+
except (ValidationError, ValueError, TypeError):
|
|
200
|
+
continue
|
|
201
|
+
|
|
202
|
+
|
|
113
203
|
def _prepopulated_payload(
|
|
114
204
|
model_admin: Any,
|
|
115
205
|
request: HttpRequest,
|
{django_admin_react-0.2.0a6 → django_admin_react-0.2.0a7}/django_admin_react/api/views/detail.py
RENAMED
|
@@ -131,6 +131,11 @@ def _build_payload(
|
|
|
131
131
|
"fields": _fields_payload(model, model_admin, obj, request, visible_names, admin_site),
|
|
132
132
|
"inlines": inlines_payload(model_admin, obj, request, admin_site),
|
|
133
133
|
"view_on_site_url": _view_on_site_url(model_admin, obj),
|
|
134
|
+
# empty_value_display (#251): the admin's configured placeholder for
|
|
135
|
+
# empty/null values (ModelAdmin override → AdminSite default "-"), so
|
|
136
|
+
# the SPA renders it instead of a hardcoded em-dash. ``str()`` keeps
|
|
137
|
+
# it a plain string on the wire (it's a SafeString in Django).
|
|
138
|
+
"empty_value_display": str(model_admin.get_empty_value_display()),
|
|
134
139
|
}
|
|
135
140
|
|
|
136
141
|
|
|
@@ -207,7 +212,9 @@ def _fieldsets_payload(
|
|
|
207
212
|
except Exception:
|
|
208
213
|
raw = ()
|
|
209
214
|
if not raw:
|
|
210
|
-
return [
|
|
215
|
+
return [
|
|
216
|
+
{"title": None, "fields": visible_names, "field_rows": [[n] for n in visible_names]}
|
|
217
|
+
]
|
|
211
218
|
|
|
212
219
|
visible_set = set(visible_names)
|
|
213
220
|
payload: list[dict[str, Any]] = []
|
|
@@ -333,7 +340,7 @@ def _descriptor_for(
|
|
|
333
340
|
form_field.help_text if form_field is not None else ""
|
|
334
341
|
)
|
|
335
342
|
|
|
336
|
-
|
|
343
|
+
descriptor = field_metadata(
|
|
337
344
|
model_field,
|
|
338
345
|
label=_field_label(model_admin, model, name),
|
|
339
346
|
required=required,
|
|
@@ -341,6 +348,17 @@ def _descriptor_for(
|
|
|
341
348
|
help_text=str(help_text),
|
|
342
349
|
value=value,
|
|
343
350
|
)
|
|
351
|
+
# radio_fields (#251): when the admin lists this choice/FK field in
|
|
352
|
+
# ``radio_fields``, hint the SPA to render radios instead of a select.
|
|
353
|
+
# Presentational only — no permission/value change.
|
|
354
|
+
if name in (getattr(model_admin, "radio_fields", None) or {}):
|
|
355
|
+
descriptor["widget"] = "radio"
|
|
356
|
+
# raw_id_fields (#251): FK/M2M fields the admin lists here render as a
|
|
357
|
+
# pk input + lookup instead of a full select (for high-cardinality
|
|
358
|
+
# relations). ``elif`` so ``radio_fields`` wins if a field is in both.
|
|
359
|
+
elif name in (getattr(model_admin, "raw_id_fields", None) or ()):
|
|
360
|
+
descriptor["widget"] = "raw_id"
|
|
361
|
+
return descriptor
|
|
344
362
|
|
|
345
363
|
|
|
346
364
|
def _readonly_callable_descriptor(
|
{django_admin_react-0.2.0a6 → django_admin_react-0.2.0a7}/django_admin_react/api/views/list.py
RENAMED
|
@@ -39,6 +39,7 @@ from django_admin_react.api.permissions import is_admin_user
|
|
|
39
39
|
from django_admin_react.api.registry import get_admin_site
|
|
40
40
|
from django_admin_react.api.registry import model_permissions
|
|
41
41
|
from django_admin_react.api.registry import resolve_model
|
|
42
|
+
from django_admin_react.api.serializers import field_type_for
|
|
42
43
|
from django_admin_react.api.serializers import label_for
|
|
43
44
|
from django_admin_react.api.serializers import safe_get_field
|
|
44
45
|
from django_admin_react.api.serializers import serialize_fk_value
|
|
@@ -191,7 +192,21 @@ class ListView(View):
|
|
|
191
192
|
"pk_field": model._meta.pk.name,
|
|
192
193
|
"permissions": model_permissions(model_admin, request),
|
|
193
194
|
"columns": columns,
|
|
195
|
+
# list_display_links (#251): the column name(s) that link to the
|
|
196
|
+
# detail page — ``ModelAdmin.get_list_display_links`` (defaults to
|
|
197
|
+
# the first column; ``[]`` when the admin set
|
|
198
|
+
# ``list_display_links = None`` to disable linking). The SPA links
|
|
199
|
+
# exactly these columns. Callable list_display entries are dropped
|
|
200
|
+
# (only string column names round-trip).
|
|
201
|
+
"list_display_links": [
|
|
202
|
+
name
|
|
203
|
+
for name in (model_admin.get_list_display_links(request, list_display) or ())
|
|
204
|
+
if isinstance(name, str)
|
|
205
|
+
],
|
|
194
206
|
"search_fields": list(model_admin.search_fields or ()),
|
|
207
|
+
# ModelAdmin.search_help_text (#445): shown under the search box,
|
|
208
|
+
# matching Django's changelist. Empty string when unset.
|
|
209
|
+
"search_help_text": str(getattr(model_admin, "search_help_text", "") or ""),
|
|
195
210
|
"filters": filters_payload(model_admin, request, admin_site=admin_site),
|
|
196
211
|
"actions": actions_payload(model_admin, request),
|
|
197
212
|
"page": page,
|
|
@@ -205,6 +220,10 @@ class ListView(View):
|
|
|
205
220
|
# "Show all N" control only when ``total`` is at/below this cap,
|
|
206
221
|
# matching Django's changelist (#385).
|
|
207
222
|
"list_max_show_all": list_max_show_all,
|
|
223
|
+
# empty_value_display (#251): the admin's placeholder for empty
|
|
224
|
+
# cells (ModelAdmin override → AdminSite default "-"), so the SPA
|
|
225
|
+
# renders it instead of a hardcoded em-dash.
|
|
226
|
+
"empty_value_display": str(model_admin.get_empty_value_display()),
|
|
208
227
|
"results": results,
|
|
209
228
|
}
|
|
210
229
|
date_hierarchy = date_hierarchy_payload(
|
|
@@ -299,9 +318,15 @@ def _columns_payload(
|
|
|
299
318
|
) -> list[dict[str, Any]]:
|
|
300
319
|
"""Build the ``columns[]`` payload for the list response.
|
|
301
320
|
|
|
302
|
-
Each entry has ``{name, label, sortable, editable}
|
|
303
|
-
|
|
304
|
-
|
|
321
|
+
Each entry has ``{name, label, sortable, editable}`` plus a
|
|
322
|
+
``type`` (the closed v1 field vocabulary) whenever the column maps
|
|
323
|
+
to a concrete model field — so the SPA can format ``datetime`` /
|
|
324
|
+
``date`` / ``time`` cells for display instead of dumping raw ISO
|
|
325
|
+
(#413). ``list_display`` callables / display methods have no field
|
|
326
|
+
and so carry no ``type``; the SPA falls back to the plain string.
|
|
327
|
+
Labels resolve through Django's ``label_for_field`` so
|
|
328
|
+
admin-customised labels (verbose name, ``short_description``, etc.)
|
|
329
|
+
are honored.
|
|
305
330
|
``editable`` is derived from ``ModelAdmin.list_editable`` — the
|
|
306
331
|
SPA renders the cell as an in-place editor when ``True`` and
|
|
307
332
|
submits changes via the bulk PATCH endpoint (Issue #61). The
|
|
@@ -322,14 +347,19 @@ def _columns_payload(
|
|
|
322
347
|
label = label_for_field(name, model_admin.model, model_admin)
|
|
323
348
|
except Exception: # pragma: no cover — defensive
|
|
324
349
|
label = name
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
350
|
+
entry: dict[str, Any] = {
|
|
351
|
+
"name": name,
|
|
352
|
+
"label": str(label),
|
|
353
|
+
"sortable": name in sortable,
|
|
354
|
+
"editable": name in editable,
|
|
355
|
+
}
|
|
356
|
+
# Only concrete model fields carry a type; a ``list_display``
|
|
357
|
+
# callable / display method resolves to ``None`` and the key is
|
|
358
|
+
# omitted (the SPA then renders the value as a plain string).
|
|
359
|
+
field = safe_get_field(model_admin.model, name) if isinstance(name, str) else None
|
|
360
|
+
if field is not None:
|
|
361
|
+
entry["type"] = field_type_for(field)
|
|
362
|
+
payload.append(entry)
|
|
333
363
|
return payload
|
|
334
364
|
|
|
335
365
|
|