django-admin-react 0.2.0a5__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.0a5 → django_admin_react-0.2.0a7}/PKG-INFO +51 -1
- {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a7}/README.md +50 -0
- {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a7}/django_admin_react/api/filters.py +85 -6
- {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a7}/django_admin_react/api/inlines.py +28 -0
- {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a7}/django_admin_react/api/inlines_write.py +15 -0
- {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a7}/django_admin_react/api/registry.py +6 -0
- {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a7}/django_admin_react/api/views/actions.py +12 -2
- {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a7}/django_admin_react/api/views/bulk.py +37 -4
- django_admin_react-0.2.0a7/django_admin_react/api/views/create.py +213 -0
- django_admin_react-0.2.0a7/django_admin_react/api/views/create_form.py +229 -0
- {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a7}/django_admin_react/api/views/detail.py +39 -9
- {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a7}/django_admin_react/api/views/list.py +106 -13
- {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a7}/django_admin_react/api/views/password.py +5 -1
- {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a7}/django_admin_react/api/views/schema.py +20 -0
- {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a7}/django_admin_react/api/views/update.py +80 -31
- {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a7}/django_admin_react/api/writes.py +27 -0
- {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a7}/django_admin_react/conf.py +9 -0
- {django_admin_react-0.2.0a5 → 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.0a5 → django_admin_react-0.2.0a7}/django_admin_react/static/admin_react/index.html +2 -2
- {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a7}/django_admin_react/templates/admin_react/index.html +8 -0
- {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a7}/django_admin_react/views.py +23 -0
- {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a7}/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/api/views/create_form.py +0 -102
- 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.0a7}/LICENSE +0 -0
- {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a7}/django_admin_react/README.md +0 -0
- {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a7}/django_admin_react/__init__.py +0 -0
- {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a7}/django_admin_react/api/README.md +0 -0
- {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a7}/django_admin_react/api/__init__.py +0 -0
- {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a7}/django_admin_react/api/dates.py +0 -0
- {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a7}/django_admin_react/api/panels.py +0 -0
- {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a7}/django_admin_react/api/permissions.py +0 -0
- {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a7}/django_admin_react/api/serializers.py +0 -0
- {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a7}/django_admin_react/api/urls.py +0 -0
- {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a7}/django_admin_react/api/views/README.md +0 -0
- {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a7}/django_admin_react/api/views/__init__.py +0 -0
- {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a7}/django_admin_react/api/views/auth.py +0 -0
- {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a7}/django_admin_react/api/views/autocomplete.py +0 -0
- {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a7}/django_admin_react/api/views/delete_preview.py +0 -0
- {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a7}/django_admin_react/api/views/destroy.py +0 -0
- {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a7}/django_admin_react/api/views/history.py +0 -0
- {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a7}/django_admin_react/api/views/registry.py +0 -0
- {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a7}/django_admin_react/apps.py +0 -0
- {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a7}/django_admin_react/audit.py +0 -0
- {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a7}/django_admin_react/pwa.py +0 -0
- {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a7}/django_admin_react/templates/README.md +0 -0
- {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a7}/django_admin_react/templates/admin_react/README.md +0 -0
- {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a7}/django_admin_react/templates/admin_react/login.html +0 -0
- {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a7}/django_admin_react/templates/admin_react/sw.js +0 -0
- {django_admin_react-0.2.0a5 → django_admin_react-0.2.0a7}/django_admin_react/urls.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,
|
|
@@ -144,18 +176,40 @@ def _spec_for_fk(
|
|
|
144
176
|
"to": {"app_label": meta.app_label, "model_name": meta.model_name},
|
|
145
177
|
}
|
|
146
178
|
# Inline up to _FK_FILTER_MAX_OPTIONS choices for tiny tables;
|
|
147
|
-
# larger tables defer to the autocomplete endpoint (#59).
|
|
179
|
+
# larger tables defer to the autocomplete endpoint (#59). Respect the
|
|
180
|
+
# FK's ``limit_choices_to`` so the offered options match Django's
|
|
181
|
+
# RelatedFieldListFilter, whose choices come from
|
|
182
|
+
# ``complex_filter(limit_choices_to)`` — a FK declared with, e.g.,
|
|
183
|
+
# ``limit_choices_to={"is_active": True}`` must not offer the rows it
|
|
184
|
+
# excludes (#273). An unset / empty / callable-returning-empty limit
|
|
185
|
+
# is falsy, so the unfiltered manager is used unchanged (and we never
|
|
186
|
+
# call ``complex_filter(None)``, which would raise).
|
|
187
|
+
base_qs = related._default_manager.all()
|
|
188
|
+
limit = field.get_limit_choices_to()
|
|
189
|
+
if limit:
|
|
190
|
+
try:
|
|
191
|
+
base_qs = related._default_manager.complex_filter(limit)
|
|
192
|
+
except Exception:
|
|
193
|
+
base_qs = related._default_manager.all()
|
|
148
194
|
try:
|
|
149
|
-
count =
|
|
195
|
+
count = base_qs.count()
|
|
150
196
|
except Exception:
|
|
151
197
|
count = _FK_FILTER_MAX_OPTIONS + 1
|
|
152
198
|
if count <= _FK_FILTER_MAX_OPTIONS:
|
|
153
199
|
from django_admin_react.api.serializers import label_for
|
|
154
200
|
|
|
155
201
|
payload["choices"] = [
|
|
156
|
-
{"value": obj.pk, "label": label_for(obj)}
|
|
157
|
-
for obj in related._default_manager.all()[:_FK_FILTER_MAX_OPTIONS]
|
|
202
|
+
{"value": obj.pk, "label": label_for(obj)} for obj in base_qs[:_FK_FILTER_MAX_OPTIONS]
|
|
158
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
|
|
159
213
|
return payload
|
|
160
214
|
|
|
161
215
|
|
|
@@ -179,10 +233,22 @@ def _spec_for_simple_filter(
|
|
|
179
233
|
lookups = list(instance.lookups(request, model_admin) or [])
|
|
180
234
|
except Exception: # pragma: no cover — admin author error
|
|
181
235
|
lookups = []
|
|
236
|
+
# The lookup the filter is currently applying — Django's
|
|
237
|
+
# ``SimpleListFilter.value()``. Crucially this includes a *default*
|
|
238
|
+
# the filter applies when no querystring param is present (a common
|
|
239
|
+
# "exclude test tenants unless opted in" pattern): such a filter
|
|
240
|
+
# returns its default from ``value()``, so the SPA can reflect the
|
|
241
|
+
# default as selected instead of showing "All" while the backend
|
|
242
|
+
# silently narrows the rows (#283). ``None`` means no selection.
|
|
243
|
+
try:
|
|
244
|
+
selected = instance.value()
|
|
245
|
+
except Exception: # pragma: no cover — admin author error
|
|
246
|
+
selected = None
|
|
182
247
|
return {
|
|
183
248
|
"name": instance.parameter_name,
|
|
184
249
|
"label": str(getattr(instance, "title", "") or instance.parameter_name),
|
|
185
250
|
"type": "custom",
|
|
251
|
+
"selected": selected,
|
|
186
252
|
"lookups": [{"value": v, "label": str(lbl)} for v, lbl in lookups],
|
|
187
253
|
}
|
|
188
254
|
|
|
@@ -242,9 +308,17 @@ def filters_payload(
|
|
|
242
308
|
if is_sensitive_field_name(field_name):
|
|
243
309
|
continue
|
|
244
310
|
|
|
245
|
-
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)
|
|
246
315
|
if field is None:
|
|
247
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
|
|
248
322
|
if isinstance(field, BooleanField):
|
|
249
323
|
out.append(_spec_for_boolean(field_name, field))
|
|
250
324
|
elif isinstance(field, ForeignKey):
|
|
@@ -302,9 +376,14 @@ def apply_filters(queryset: QuerySet, model_admin: ModelAdmin, request: HttpRequ
|
|
|
302
376
|
if raw_value is None or raw_value == "":
|
|
303
377
|
continue
|
|
304
378
|
|
|
305
|
-
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)
|
|
306
383
|
if field is None:
|
|
307
384
|
continue
|
|
385
|
+
if is_sensitive_field_name(field.name):
|
|
386
|
+
continue
|
|
308
387
|
|
|
309
388
|
try:
|
|
310
389
|
if isinstance(field, BooleanField):
|
|
@@ -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.0a7}/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.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.0a5 → 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"
|
{django_admin_react-0.2.0a5 → django_admin_react-0.2.0a7}/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}
|