django-admin-react 0.2.0a4__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.0a4 → django_admin_react-0.2.0a6}/PKG-INFO +37 -31
- {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a6}/README.md +36 -30
- {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a6}/django_admin_react/api/dates.py +9 -6
- {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a6}/django_admin_react/api/filters.py +44 -10
- {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a6}/django_admin_react/api/inlines.py +79 -9
- {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a6}/django_admin_react/api/inlines_write.py +15 -0
- {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a6}/django_admin_react/api/registry.py +39 -0
- {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a6}/django_admin_react/api/serializers.py +30 -4
- {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a6}/django_admin_react/api/urls.py +15 -6
- {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a6}/django_admin_react/api/views/README.md +1 -0
- {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a6}/django_admin_react/api/views/actions.py +24 -5
- {django_admin_react-0.2.0a4 → 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.0a4 → django_admin_react-0.2.0a6}/django_admin_react/api/views/create_form.py +43 -0
- {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a6}/django_admin_react/api/views/detail.py +110 -20
- {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a6}/django_admin_react/api/views/history.py +3 -1
- {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a6}/django_admin_react/api/views/list.py +139 -9
- django_admin_react-0.2.0a6/django_admin_react/api/views/password.py +161 -0
- {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a6}/django_admin_react/api/views/registry.py +3 -1
- {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a6}/django_admin_react/api/views/schema.py +20 -0
- {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a6}/django_admin_react/api/views/update.py +81 -32
- {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a6}/django_admin_react/api/writes.py +38 -3
- {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a6}/django_admin_react/conf.py +9 -0
- {django_admin_react-0.2.0a4 → 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.0a4 → django_admin_react-0.2.0a6}/django_admin_react/static/admin_react/index.html +2 -2
- django_admin_react-0.2.0a6/django_admin_react/templates/README.md +27 -0
- {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a6}/django_admin_react/templates/admin_react/index.html +15 -3
- {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a6}/django_admin_react/views.py +28 -1
- {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a6}/pyproject.toml +12 -1
- django_admin_react-0.2.0a4/django_admin_react/api/views/create.py +0 -143
- django_admin_react-0.2.0a4/django_admin_react/static/admin_react/assets/index-Cf63Q57m.css +0 -1
- django_admin_react-0.2.0a4/django_admin_react/static/admin_react/assets/index-Ch1wOBLJ.js +0 -9
- django_admin_react-0.2.0a4/django_admin_react/static/admin_react/assets/index-Ch1wOBLJ.js.map +0 -1
- {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a6}/LICENSE +0 -0
- {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a6}/django_admin_react/README.md +0 -0
- {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a6}/django_admin_react/__init__.py +0 -0
- {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a6}/django_admin_react/api/README.md +0 -0
- {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a6}/django_admin_react/api/__init__.py +0 -0
- {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a6}/django_admin_react/api/panels.py +0 -0
- {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a6}/django_admin_react/api/permissions.py +0 -0
- {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a6}/django_admin_react/api/views/__init__.py +0 -0
- {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a6}/django_admin_react/api/views/auth.py +0 -0
- {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a6}/django_admin_react/api/views/autocomplete.py +0 -0
- {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a6}/django_admin_react/api/views/delete_preview.py +0 -0
- {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a6}/django_admin_react/api/views/destroy.py +0 -0
- {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a6}/django_admin_react/apps.py +0 -0
- {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a6}/django_admin_react/audit.py +0 -0
- {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a6}/django_admin_react/pwa.py +0 -0
- {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a6}/django_admin_react/templates/admin_react/README.md +0 -0
- {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a6}/django_admin_react/templates/admin_react/login.html +0 -0
- {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a6}/django_admin_react/templates/admin_react/sw.js +0 -0
- {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a6}/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.0a6
|
|
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
|
|
@@ -82,15 +82,15 @@ throwaway example server).
|
|
|
82
82
|
|
|
83
83
|
| Sign in (package login) | Registry / home |
|
|
84
84
|
| -------------------------------------------------- | ----------------------------------------------------- |
|
|
85
|
-
|  |  |
|
|
85
|
+
|  |  |
|
|
86
86
|
|
|
87
87
|
| List view (`list_display` + search) | Detail view |
|
|
88
88
|
| ------------------------------------------------------- | ---------------------------------------------------- |
|
|
89
|
-
|  |  |
|
|
89
|
+
|  |  |
|
|
90
90
|
|
|
91
91
|
| Mobile (375 px) | API: `GET /api/v1/registry/` |
|
|
92
92
|
| ---------------------------------------------------------- | ---------------------------------------------------------- |
|
|
93
|
-
|  |  |
|
|
93
|
+
|  |  |
|
|
94
94
|
|
|
95
95
|
Screenshots use deterministic synthetic fixtures (no real names,
|
|
96
96
|
emails, account numbers, or PII).
|
|
@@ -411,35 +411,41 @@ customisations.
|
|
|
411
411
|
|
|
412
412
|
---
|
|
413
413
|
|
|
414
|
-
## Feature status (
|
|
414
|
+
## Feature status (alpha — currently `0.2.0a*` on PyPI)
|
|
415
415
|
|
|
416
|
-
|
|
416
|
+
The **backend** — the `ModelAdmin`-driven REST API — is the stable,
|
|
417
|
+
complete surface and the table below tracks it. The **React SPA** that
|
|
418
|
+
consumes it is in active development; to keep this README from drifting,
|
|
419
|
+
per-feature *SPA* (UI) status is **not** duplicated here — it is tracked
|
|
420
|
+
live in the [frontend implementation tracker (#160)](https://github.com/MartinCastroAlvarez/django-admin-react/issues/160)
|
|
421
|
+
and the [project board](https://github.com/users/MartinCastroAlvarez/projects/3).
|
|
422
|
+
|
|
423
|
+
| `ModelAdmin` surface | Backend (REST API) |
|
|
417
424
|
| ------------------------------------------------------ | --------------------------------------------------------------- |
|
|
418
|
-
| Registry / list / detail / create / update / delete | ✅
|
|
419
|
-
| `list_display`, `sortable_by`, `search_fields` | ✅
|
|
420
|
-
| `list_filter` (boolean / choice / FK / date / Simple) | ✅
|
|
421
|
-
| `date_hierarchy` | ✅
|
|
422
|
-
| `list_editable` + bulk PATCH | ✅
|
|
423
|
-
| `actions` (custom + bulk runner) | ✅
|
|
424
|
-
| `autocomplete_fields` / `raw_id_fields` | ✅
|
|
425
|
-
| `ManyToManyField` read + write | ✅
|
|
426
|
-
| `inlines` (TabularInline / StackedInline) — read
|
|
427
|
-
| `
|
|
428
|
-
| `FileField` / `ImageField` —
|
|
429
|
-
| `
|
|
430
|
-
|
|
|
431
|
-
| `register_field_type` + per-model
|
|
432
|
-
|
|
|
433
|
-
|
|
|
434
|
-
|
|
|
435
|
-
|
|
|
436
|
-
| PWA
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
backend
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
for live status.
|
|
425
|
+
| Registry / list / detail / create / update / delete | ✅ |
|
|
426
|
+
| `list_display`, `sortable_by`, `search_fields` | ✅ |
|
|
427
|
+
| `list_filter` (boolean / choice / FK / date / Simple) | ✅ |
|
|
428
|
+
| `date_hierarchy` | ✅ |
|
|
429
|
+
| `list_editable` + bulk PATCH | ✅ |
|
|
430
|
+
| `actions` (custom + bulk runner) | ✅ |
|
|
431
|
+
| `autocomplete_fields` / `raw_id_fields` | ✅ |
|
|
432
|
+
| `ManyToManyField` read + write | ✅ |
|
|
433
|
+
| `inlines` (TabularInline / StackedInline) — read + write | ✅ |
|
|
434
|
+
| `FileField` / `ImageField` — read | ✅ |
|
|
435
|
+
| `FileField` / `ImageField` — multipart upload | 🟡 [#241](https://github.com/MartinCastroAlvarez/django-admin-react/issues/241) |
|
|
436
|
+
| `JSONField` / `ArrayField` / range — read | ✅ |
|
|
437
|
+
| range fields — write coercion | 🟡 [#238](https://github.com/MartinCastroAlvarez/django-admin-react/issues/238) |
|
|
438
|
+
| `register_field_type` + per-model extension hook | ✅ |
|
|
439
|
+
| React login / logout (Django session + CSRF) | ✅ |
|
|
440
|
+
| Password set / change (`UserAdmin` parity) | ✅ |
|
|
441
|
+
| Session-expiry re-login contract | ✅ |
|
|
442
|
+
| OpenAPI 3.1 schema at `/api/v1/schema/` | ✅ |
|
|
443
|
+
| PWA manifest + service worker (cache-purge on logout) | ✅ |
|
|
444
|
+
|
|
445
|
+
✅ = shipped in the current alpha. 🟡 = not yet built (tracked). This
|
|
446
|
+
column is the **backend** capability only — for which surfaces the React
|
|
447
|
+
UI renders today, see the [frontend tracker (#160)](https://github.com/MartinCastroAlvarez/django-admin-react/issues/160).
|
|
448
|
+
[`ACCEPTANCE.md`](ACCEPTANCE.md) carries the full criterion-by-criterion list.
|
|
443
449
|
|
|
444
450
|
---
|
|
445
451
|
|
|
@@ -51,15 +51,15 @@ throwaway example server).
|
|
|
51
51
|
|
|
52
52
|
| Sign in (package login) | Registry / home |
|
|
53
53
|
| -------------------------------------------------- | ----------------------------------------------------- |
|
|
54
|
-
|  |  |
|
|
54
|
+
|  |  |
|
|
55
55
|
|
|
56
56
|
| List view (`list_display` + search) | Detail view |
|
|
57
57
|
| ------------------------------------------------------- | ---------------------------------------------------- |
|
|
58
|
-
|  |  |
|
|
58
|
+
|  |  |
|
|
59
59
|
|
|
60
60
|
| Mobile (375 px) | API: `GET /api/v1/registry/` |
|
|
61
61
|
| ---------------------------------------------------------- | ---------------------------------------------------------- |
|
|
62
|
-
|  |  |
|
|
62
|
+
|  |  |
|
|
63
63
|
|
|
64
64
|
Screenshots use deterministic synthetic fixtures (no real names,
|
|
65
65
|
emails, account numbers, or PII).
|
|
@@ -380,35 +380,41 @@ customisations.
|
|
|
380
380
|
|
|
381
381
|
---
|
|
382
382
|
|
|
383
|
-
## Feature status (
|
|
383
|
+
## Feature status (alpha — currently `0.2.0a*` on PyPI)
|
|
384
384
|
|
|
385
|
-
|
|
385
|
+
The **backend** — the `ModelAdmin`-driven REST API — is the stable,
|
|
386
|
+
complete surface and the table below tracks it. The **React SPA** that
|
|
387
|
+
consumes it is in active development; to keep this README from drifting,
|
|
388
|
+
per-feature *SPA* (UI) status is **not** duplicated here — it is tracked
|
|
389
|
+
live in the [frontend implementation tracker (#160)](https://github.com/MartinCastroAlvarez/django-admin-react/issues/160)
|
|
390
|
+
and the [project board](https://github.com/users/MartinCastroAlvarez/projects/3).
|
|
391
|
+
|
|
392
|
+
| `ModelAdmin` surface | Backend (REST API) |
|
|
386
393
|
| ------------------------------------------------------ | --------------------------------------------------------------- |
|
|
387
|
-
| Registry / list / detail / create / update / delete | ✅
|
|
388
|
-
| `list_display`, `sortable_by`, `search_fields` | ✅
|
|
389
|
-
| `list_filter` (boolean / choice / FK / date / Simple) | ✅
|
|
390
|
-
| `date_hierarchy` | ✅
|
|
391
|
-
| `list_editable` + bulk PATCH | ✅
|
|
392
|
-
| `actions` (custom + bulk runner) | ✅
|
|
393
|
-
| `autocomplete_fields` / `raw_id_fields` | ✅
|
|
394
|
-
| `ManyToManyField` read + write | ✅
|
|
395
|
-
| `inlines` (TabularInline / StackedInline) — read
|
|
396
|
-
| `
|
|
397
|
-
| `FileField` / `ImageField` —
|
|
398
|
-
| `
|
|
399
|
-
|
|
|
400
|
-
| `register_field_type` + per-model
|
|
401
|
-
|
|
|
402
|
-
|
|
|
403
|
-
|
|
|
404
|
-
|
|
|
405
|
-
| PWA
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
backend
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
for live status.
|
|
394
|
+
| Registry / list / detail / create / update / delete | ✅ |
|
|
395
|
+
| `list_display`, `sortable_by`, `search_fields` | ✅ |
|
|
396
|
+
| `list_filter` (boolean / choice / FK / date / Simple) | ✅ |
|
|
397
|
+
| `date_hierarchy` | ✅ |
|
|
398
|
+
| `list_editable` + bulk PATCH | ✅ |
|
|
399
|
+
| `actions` (custom + bulk runner) | ✅ |
|
|
400
|
+
| `autocomplete_fields` / `raw_id_fields` | ✅ |
|
|
401
|
+
| `ManyToManyField` read + write | ✅ |
|
|
402
|
+
| `inlines` (TabularInline / StackedInline) — read + write | ✅ |
|
|
403
|
+
| `FileField` / `ImageField` — read | ✅ |
|
|
404
|
+
| `FileField` / `ImageField` — multipart upload | 🟡 [#241](https://github.com/MartinCastroAlvarez/django-admin-react/issues/241) |
|
|
405
|
+
| `JSONField` / `ArrayField` / range — read | ✅ |
|
|
406
|
+
| range fields — write coercion | 🟡 [#238](https://github.com/MartinCastroAlvarez/django-admin-react/issues/238) |
|
|
407
|
+
| `register_field_type` + per-model extension hook | ✅ |
|
|
408
|
+
| React login / logout (Django session + CSRF) | ✅ |
|
|
409
|
+
| Password set / change (`UserAdmin` parity) | ✅ |
|
|
410
|
+
| Session-expiry re-login contract | ✅ |
|
|
411
|
+
| OpenAPI 3.1 schema at `/api/v1/schema/` | ✅ |
|
|
412
|
+
| PWA manifest + service worker (cache-purge on logout) | ✅ |
|
|
413
|
+
|
|
414
|
+
✅ = shipped in the current alpha. 🟡 = not yet built (tracked). This
|
|
415
|
+
column is the **backend** capability only — for which surfaces the React
|
|
416
|
+
UI renders today, see the [frontend tracker (#160)](https://github.com/MartinCastroAlvarez/django-admin-react/issues/160).
|
|
417
|
+
[`ACCEPTANCE.md`](ACCEPTANCE.md) carries the full criterion-by-criterion list.
|
|
412
418
|
|
|
413
419
|
---
|
|
414
420
|
|
|
@@ -34,6 +34,7 @@ from typing import Final
|
|
|
34
34
|
from django.contrib.admin.options import ModelAdmin
|
|
35
35
|
from django.db.models import Count
|
|
36
36
|
from django.db.models import QuerySet
|
|
37
|
+
from django.db.models.functions import Extract
|
|
37
38
|
from django.db.models.functions import ExtractDay
|
|
38
39
|
from django.db.models.functions import ExtractMonth
|
|
39
40
|
from django.db.models.functions import ExtractYear
|
|
@@ -98,12 +99,13 @@ def apply_filter(
|
|
|
98
99
|
all handle these natively). No raw SQL.
|
|
99
100
|
"""
|
|
100
101
|
lookups: dict[str, int] = {}
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
102
|
+
year, month, day = active.get("year"), active.get("month"), active.get("day")
|
|
103
|
+
if year is not None:
|
|
104
|
+
lookups[f"{field}__year"] = year
|
|
105
|
+
if month is not None:
|
|
106
|
+
lookups[f"{field}__month"] = month
|
|
107
|
+
if day is not None:
|
|
108
|
+
lookups[f"{field}__day"] = day
|
|
107
109
|
if not lookups:
|
|
108
110
|
return queryset
|
|
109
111
|
return queryset.filter(**lookups)
|
|
@@ -128,6 +130,7 @@ def build_buckets(
|
|
|
128
130
|
excluded (they don't appear in any bucket).
|
|
129
131
|
"""
|
|
130
132
|
year, month, day = active.get("year"), active.get("month"), active.get("day")
|
|
133
|
+
extractor: type[Extract]
|
|
131
134
|
if year is None:
|
|
132
135
|
extractor, _name = ExtractYear, "year"
|
|
133
136
|
elif month is None:
|
|
@@ -43,6 +43,7 @@ from django.contrib.admin.sites import AdminSite
|
|
|
43
43
|
from django.db.models import BooleanField
|
|
44
44
|
from django.db.models import DateField
|
|
45
45
|
from django.db.models import DateTimeField
|
|
46
|
+
from django.db.models import Field
|
|
46
47
|
from django.db.models import ForeignKey
|
|
47
48
|
from django.db.models import Model
|
|
48
49
|
from django.db.models import QuerySet
|
|
@@ -78,12 +79,17 @@ def _entry_spec(entry: Any) -> tuple[str | None, type | None]:
|
|
|
78
79
|
return None, None
|
|
79
80
|
|
|
80
81
|
|
|
81
|
-
def _safe_get_field(model: type[Model], name: str):
|
|
82
|
-
"""Return ``model._meta.get_field(name)`` or ``None``.
|
|
82
|
+
def _safe_get_field(model: type[Model], name: str) -> Field | None:
|
|
83
|
+
"""Return ``model._meta.get_field(name)`` or ``None``.
|
|
84
|
+
|
|
85
|
+
Reverse relations / generic FKs (not concrete ``Field``s) collapse to
|
|
86
|
+
``None`` — consistent with ``serializers.safe_get_field``.
|
|
87
|
+
"""
|
|
83
88
|
try:
|
|
84
|
-
|
|
89
|
+
field = model._meta.get_field(name)
|
|
85
90
|
except Exception:
|
|
86
91
|
return None
|
|
92
|
+
return field if isinstance(field, Field) else None
|
|
87
93
|
|
|
88
94
|
|
|
89
95
|
def _spec_for_boolean(field_name: str, field: Any) -> dict[str, Any]:
|
|
@@ -119,9 +125,12 @@ def _spec_for_fk(
|
|
|
119
125
|
unregistered model (see issue #89, defense-in-depth).
|
|
120
126
|
"""
|
|
121
127
|
related = field.related_model
|
|
122
|
-
|
|
123
|
-
|
|
128
|
+
# ``related_model`` is the resolved model class once the admin is
|
|
129
|
+
# loaded; ``"self"`` is only a definition-time sentinel. Guard both
|
|
130
|
+
# ``None`` and the str so the type narrows to ``type[Model]``.
|
|
131
|
+
if related is None or isinstance(related, str):
|
|
124
132
|
return None
|
|
133
|
+
meta = related._meta
|
|
125
134
|
# #89: drop the descriptor entirely if the related model isn't in
|
|
126
135
|
# the configured admin site. This keeps the closed-vocabulary
|
|
127
136
|
# posture tight (the SPA only learns about FK filters it can
|
|
@@ -135,17 +144,30 @@ def _spec_for_fk(
|
|
|
135
144
|
"to": {"app_label": meta.app_label, "model_name": meta.model_name},
|
|
136
145
|
}
|
|
137
146
|
# Inline up to _FK_FILTER_MAX_OPTIONS choices for tiny tables;
|
|
138
|
-
# 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()
|
|
139
162
|
try:
|
|
140
|
-
count =
|
|
163
|
+
count = base_qs.count()
|
|
141
164
|
except Exception:
|
|
142
165
|
count = _FK_FILTER_MAX_OPTIONS + 1
|
|
143
166
|
if count <= _FK_FILTER_MAX_OPTIONS:
|
|
144
167
|
from django_admin_react.api.serializers import label_for
|
|
145
168
|
|
|
146
169
|
payload["choices"] = [
|
|
147
|
-
{"value": obj.pk, "label": label_for(obj)}
|
|
148
|
-
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]
|
|
149
171
|
]
|
|
150
172
|
return payload
|
|
151
173
|
|
|
@@ -170,10 +192,22 @@ def _spec_for_simple_filter(
|
|
|
170
192
|
lookups = list(instance.lookups(request, model_admin) or [])
|
|
171
193
|
except Exception: # pragma: no cover — admin author error
|
|
172
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
|
|
173
206
|
return {
|
|
174
207
|
"name": instance.parameter_name,
|
|
175
208
|
"label": str(getattr(instance, "title", "") or instance.parameter_name),
|
|
176
209
|
"type": "custom",
|
|
210
|
+
"selected": selected,
|
|
177
211
|
"lookups": [{"value": v, "label": str(lbl)} for v, lbl in lookups],
|
|
178
212
|
}
|
|
179
213
|
|
|
@@ -290,7 +324,7 @@ def apply_filters(queryset: QuerySet, model_admin: ModelAdmin, request: HttpRequ
|
|
|
290
324
|
if field_name is None:
|
|
291
325
|
continue
|
|
292
326
|
raw_value = request.GET.get(field_name)
|
|
293
|
-
if raw_value
|
|
327
|
+
if raw_value is None or raw_value == "":
|
|
294
328
|
continue
|
|
295
329
|
|
|
296
330
|
field = _safe_get_field(model, field_name)
|
|
@@ -27,6 +27,7 @@ from typing import Any
|
|
|
27
27
|
from django.contrib.admin.options import InlineModelAdmin
|
|
28
28
|
from django.contrib.admin.options import ModelAdmin
|
|
29
29
|
from django.contrib.admin.utils import label_for_field
|
|
30
|
+
from django.contrib.admin.utils import lookup_field
|
|
30
31
|
from django.db.models import ForeignKey
|
|
31
32
|
from django.db.models import ManyToManyField
|
|
32
33
|
from django.db.models import Model
|
|
@@ -42,7 +43,10 @@ from django_admin_react.api.serializers import serialize_value
|
|
|
42
43
|
|
|
43
44
|
|
|
44
45
|
def inlines_payload(
|
|
45
|
-
model_admin: ModelAdmin,
|
|
46
|
+
model_admin: ModelAdmin,
|
|
47
|
+
parent: Model,
|
|
48
|
+
request: HttpRequest,
|
|
49
|
+
admin_site: Any = None,
|
|
46
50
|
) -> list[dict[str, Any]]:
|
|
47
51
|
"""Build the ``inlines`` block of the detail response.
|
|
48
52
|
|
|
@@ -74,7 +78,7 @@ def inlines_payload(
|
|
|
74
78
|
out: list[dict[str, Any]] = []
|
|
75
79
|
inline_instances = _get_inline_instances(model_admin, parent, request)
|
|
76
80
|
for inline in inline_instances:
|
|
77
|
-
entry = _spec_for_inline(inline, parent, request)
|
|
81
|
+
entry = _spec_for_inline(inline, parent, request, admin_site)
|
|
78
82
|
if entry is not None:
|
|
79
83
|
out.append(entry)
|
|
80
84
|
return out
|
|
@@ -96,8 +100,31 @@ def _get_inline_instances(
|
|
|
96
100
|
return []
|
|
97
101
|
|
|
98
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
|
+
|
|
99
123
|
def _spec_for_inline(
|
|
100
|
-
inline: InlineModelAdmin,
|
|
124
|
+
inline: InlineModelAdmin,
|
|
125
|
+
parent: Model,
|
|
126
|
+
request: HttpRequest,
|
|
127
|
+
admin_site: Any = None,
|
|
101
128
|
) -> dict[str, Any] | None:
|
|
102
129
|
"""Build one inline's metadata + rows payload.
|
|
103
130
|
|
|
@@ -126,7 +153,7 @@ def _spec_for_inline(
|
|
|
126
153
|
|
|
127
154
|
rows: list[dict[str, Any]] = []
|
|
128
155
|
if can_view:
|
|
129
|
-
rows = _rows_for_inline(inline, parent, fk_name, visible_fields, request)
|
|
156
|
+
rows = _rows_for_inline(inline, parent, fk_name, visible_fields, request, admin_site)
|
|
130
157
|
|
|
131
158
|
return {
|
|
132
159
|
"name": fk_name + "_set" if not hasattr(child_model, fk_name + "_set") else fk_name,
|
|
@@ -141,6 +168,14 @@ def _spec_for_inline(
|
|
|
141
168
|
"can_add": can_add,
|
|
142
169
|
"can_change": can_change,
|
|
143
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),
|
|
144
179
|
"fields": fields_meta,
|
|
145
180
|
"rows": rows,
|
|
146
181
|
}
|
|
@@ -161,7 +196,9 @@ def _resolve_fk_name(inline: InlineModelAdmin, parent: Model) -> str | None:
|
|
|
161
196
|
if isinstance(field, ForeignKey):
|
|
162
197
|
related = field.related_model
|
|
163
198
|
if related is parent_class or (
|
|
164
|
-
related is not None
|
|
199
|
+
related is not None
|
|
200
|
+
and not isinstance(related, str)
|
|
201
|
+
and issubclass(parent_class, related)
|
|
165
202
|
):
|
|
166
203
|
return field.name
|
|
167
204
|
return None
|
|
@@ -183,7 +220,10 @@ def _visible_inline_fields(
|
|
|
183
220
|
visible = [
|
|
184
221
|
name
|
|
185
222
|
for name in declared
|
|
186
|
-
if name
|
|
223
|
+
if isinstance(name, str)
|
|
224
|
+
and name not in excluded
|
|
225
|
+
and name != fk_back
|
|
226
|
+
and not is_sensitive_field_name(name)
|
|
187
227
|
]
|
|
188
228
|
return filter_sensitive(visible)
|
|
189
229
|
|
|
@@ -208,6 +248,7 @@ def _fields_meta(
|
|
|
208
248
|
readonly = set(inline.get_readonly_fields(request, None) or ())
|
|
209
249
|
out: list[dict[str, Any]] = []
|
|
210
250
|
for name in visible_fields:
|
|
251
|
+
label: Any
|
|
211
252
|
try:
|
|
212
253
|
label = label_for_field(name, child_model, inline)
|
|
213
254
|
except Exception: # pragma: no cover
|
|
@@ -236,6 +277,7 @@ def _rows_for_inline(
|
|
|
236
277
|
fk_name: str,
|
|
237
278
|
visible_fields: list[str],
|
|
238
279
|
request: HttpRequest,
|
|
280
|
+
admin_site: Any = None,
|
|
239
281
|
) -> list[dict[str, Any]]:
|
|
240
282
|
"""Fetch + serialize the child rows attached to ``parent``."""
|
|
241
283
|
try:
|
|
@@ -251,15 +293,43 @@ def _rows_for_inline(
|
|
|
251
293
|
model_field = inline.model._meta.get_field(name)
|
|
252
294
|
except Exception:
|
|
253
295
|
model_field = None
|
|
296
|
+
if model_field is None:
|
|
297
|
+
# No underlying model field: the inline lists a display
|
|
298
|
+
# method / callable (the `@admin.display def x(self, obj)`
|
|
299
|
+
# pattern, called with `obj`). Resolve via Django's own
|
|
300
|
+
# `lookup_field` (admin-first) so methods defined on the
|
|
301
|
+
# *inline admin* resolve, not just methods on the model
|
|
302
|
+
# instance. A naive `getattr(obj, name)` misses admin
|
|
303
|
+
# methods and returns None — the inline-row "—" bug
|
|
304
|
+
# (mirrors the detail-view fix in #232).
|
|
305
|
+
try:
|
|
306
|
+
_f, _attr, value = lookup_field(name, obj, inline)
|
|
307
|
+
except Exception:
|
|
308
|
+
# Guard the whole fallback: a readonly property can
|
|
309
|
+
# *raise* (not just be missing), and getattr's default
|
|
310
|
+
# only swallows AttributeError, so any other exception
|
|
311
|
+
# from the getter would 500 the parent detail (#275).
|
|
312
|
+
try:
|
|
313
|
+
value = getattr(obj, name, None)
|
|
314
|
+
if callable(value):
|
|
315
|
+
value = value()
|
|
316
|
+
except Exception:
|
|
317
|
+
value = None
|
|
318
|
+
fields_payload[name] = serialize_value(value)
|
|
319
|
+
continue
|
|
254
320
|
value = getattr(obj, name, None)
|
|
255
321
|
if isinstance(model_field, ForeignKey):
|
|
256
|
-
fields_payload[name] = serialize_fk_value(
|
|
322
|
+
fields_payload[name] = serialize_fk_value(
|
|
323
|
+
value, admin_site=admin_site, request=request
|
|
324
|
+
)
|
|
257
325
|
elif isinstance(model_field, ManyToManyField):
|
|
258
326
|
try:
|
|
259
|
-
related = list(value.all())
|
|
327
|
+
related = list(value.all()) if value is not None else []
|
|
260
328
|
except Exception:
|
|
261
329
|
related = []
|
|
262
|
-
fields_payload[name] = [
|
|
330
|
+
fields_payload[name] = [
|
|
331
|
+
serialize_fk_value(r, admin_site=admin_site, request=request) for r in related
|
|
332
|
+
]
|
|
263
333
|
else:
|
|
264
334
|
fields_payload[name] = serialize_value(value, field=model_field)
|
|
265
335
|
rows.append({"pk": obj.pk, "label": label_for(obj), "fields": fields_payload})
|
{django_admin_react-0.2.0a4 → 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.0a4 → django_admin_react-0.2.0a6}/django_admin_react/api/registry.py
RENAMED
|
@@ -352,3 +352,42 @@ def save_options(
|
|
|
352
352
|
"save_as": save_as,
|
|
353
353
|
"save_as_continue": save_as_continue,
|
|
354
354
|
}
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def password_change_form_class(model_admin: ModelAdmin) -> type | None:
|
|
358
|
+
"""Return the admin's declared password-change form class, or ``None``.
|
|
359
|
+
|
|
360
|
+
Django's ``UserAdmin`` declares ``change_password_form`` (default
|
|
361
|
+
``django.contrib.auth.forms.AdminPasswordChangeForm``) and registers a
|
|
362
|
+
dedicated ``<id>/password/`` view; a plain ``ModelAdmin`` does neither.
|
|
363
|
+
We treat the presence of a ``change_password_form`` attribute as the
|
|
364
|
+
signal that this admin intends password-set support — and reuse *that*
|
|
365
|
+
form, so the package never invents its own password handling (rule 1:
|
|
366
|
+
``ModelAdmin`` is the only source of truth). Models whose admin lacks
|
|
367
|
+
the attribute have no password sub-resource (the caller 404s, exactly
|
|
368
|
+
as Django's router 404s ``/password/`` for a non-``UserAdmin`` model).
|
|
369
|
+
"""
|
|
370
|
+
form_class = getattr(model_admin, "change_password_form", None)
|
|
371
|
+
return form_class if isinstance(form_class, type) else None
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def password_change_meta(
|
|
375
|
+
model_admin: ModelAdmin,
|
|
376
|
+
request: HttpRequest,
|
|
377
|
+
obj: Model,
|
|
378
|
+
) -> dict[str, bool]:
|
|
379
|
+
"""Detail-payload block describing the password-set affordance (#252).
|
|
380
|
+
|
|
381
|
+
``supported`` is ``True`` only when the admin exposes a password-change
|
|
382
|
+
form **and** the request holds change permission on the object — so the
|
|
383
|
+
SPA shows "Set password" exactly when the POST would be accepted, never
|
|
384
|
+
a button that 403s. No password material is ever surfaced here; this is
|
|
385
|
+
purely a capability flag (the field itself stays hidden by the
|
|
386
|
+
sensitive-name denylist).
|
|
387
|
+
"""
|
|
388
|
+
return {
|
|
389
|
+
"supported": bool(
|
|
390
|
+
password_change_form_class(model_admin) is not None
|
|
391
|
+
and model_admin.has_change_permission(request, obj)
|
|
392
|
+
),
|
|
393
|
+
}
|