django-admin-react 0.2.0a3__tar.gz → 0.2.0a5__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.0a3 → django_admin_react-0.2.0a5}/PKG-INFO +37 -31
- {django_admin_react-0.2.0a3 → django_admin_react-0.2.0a5}/README.md +36 -30
- {django_admin_react-0.2.0a3 → django_admin_react-0.2.0a5}/django_admin_react/api/dates.py +9 -6
- {django_admin_react-0.2.0a3 → django_admin_react-0.2.0a5}/django_admin_react/api/filters.py +15 -6
- {django_admin_react-0.2.0a3 → django_admin_react-0.2.0a5}/django_admin_react/api/inlines.py +72 -10
- {django_admin_react-0.2.0a3 → django_admin_react-0.2.0a5}/django_admin_react/api/registry.py +39 -0
- {django_admin_react-0.2.0a3 → django_admin_react-0.2.0a5}/django_admin_react/api/serializers.py +46 -5
- {django_admin_react-0.2.0a3 → django_admin_react-0.2.0a5}/django_admin_react/api/urls.py +15 -6
- {django_admin_react-0.2.0a3 → django_admin_react-0.2.0a5}/django_admin_react/api/views/README.md +1 -0
- {django_admin_react-0.2.0a3 → django_admin_react-0.2.0a5}/django_admin_react/api/views/actions.py +24 -5
- {django_admin_react-0.2.0a3 → django_admin_react-0.2.0a5}/django_admin_react/api/views/create.py +1 -1
- {django_admin_react-0.2.0a3 → django_admin_react-0.2.0a5}/django_admin_react/api/views/create_form.py +6 -0
- {django_admin_react-0.2.0a3 → django_admin_react-0.2.0a5}/django_admin_react/api/views/detail.py +90 -12
- {django_admin_react-0.2.0a3 → django_admin_react-0.2.0a5}/django_admin_react/api/views/history.py +3 -1
- {django_admin_react-0.2.0a3 → django_admin_react-0.2.0a5}/django_admin_react/api/views/list.py +79 -8
- django_admin_react-0.2.0a5/django_admin_react/api/views/password.py +157 -0
- {django_admin_react-0.2.0a3 → django_admin_react-0.2.0a5}/django_admin_react/api/views/registry.py +3 -1
- {django_admin_react-0.2.0a3 → django_admin_react-0.2.0a5}/django_admin_react/api/views/update.py +1 -1
- {django_admin_react-0.2.0a3 → django_admin_react-0.2.0a5}/django_admin_react/api/writes.py +11 -3
- {django_admin_react-0.2.0a3 → django_admin_react-0.2.0a5}/django_admin_react/static/admin_react/.vite/manifest.json +2 -2
- django_admin_react-0.2.0a5/django_admin_react/static/admin_react/assets/index-BncyUUo8.js +8 -0
- django_admin_react-0.2.0a5/django_admin_react/static/admin_react/assets/index-Bt-X3hQW.css +1 -0
- {django_admin_react-0.2.0a3 → django_admin_react-0.2.0a5}/django_admin_react/static/admin_react/index.html +2 -2
- django_admin_react-0.2.0a5/django_admin_react/templates/README.md +27 -0
- {django_admin_react-0.2.0a3 → django_admin_react-0.2.0a5}/django_admin_react/templates/admin_react/index.html +11 -0
- {django_admin_react-0.2.0a3 → django_admin_react-0.2.0a5}/django_admin_react/views.py +5 -1
- {django_admin_react-0.2.0a3 → django_admin_react-0.2.0a5}/pyproject.toml +12 -1
- django_admin_react-0.2.0a3/django_admin_react/static/admin_react/assets/index-BOdTCQF7.js +0 -9
- django_admin_react-0.2.0a3/django_admin_react/static/admin_react/assets/index-BOdTCQF7.js.map +0 -1
- django_admin_react-0.2.0a3/django_admin_react/static/admin_react/assets/index-BgIZIHRa.css +0 -1
- {django_admin_react-0.2.0a3 → django_admin_react-0.2.0a5}/LICENSE +0 -0
- {django_admin_react-0.2.0a3 → django_admin_react-0.2.0a5}/django_admin_react/README.md +0 -0
- {django_admin_react-0.2.0a3 → django_admin_react-0.2.0a5}/django_admin_react/__init__.py +0 -0
- {django_admin_react-0.2.0a3 → django_admin_react-0.2.0a5}/django_admin_react/api/README.md +0 -0
- {django_admin_react-0.2.0a3 → django_admin_react-0.2.0a5}/django_admin_react/api/__init__.py +0 -0
- {django_admin_react-0.2.0a3 → django_admin_react-0.2.0a5}/django_admin_react/api/inlines_write.py +0 -0
- {django_admin_react-0.2.0a3 → django_admin_react-0.2.0a5}/django_admin_react/api/panels.py +0 -0
- {django_admin_react-0.2.0a3 → django_admin_react-0.2.0a5}/django_admin_react/api/permissions.py +0 -0
- {django_admin_react-0.2.0a3 → django_admin_react-0.2.0a5}/django_admin_react/api/views/__init__.py +0 -0
- {django_admin_react-0.2.0a3 → django_admin_react-0.2.0a5}/django_admin_react/api/views/auth.py +0 -0
- {django_admin_react-0.2.0a3 → django_admin_react-0.2.0a5}/django_admin_react/api/views/autocomplete.py +0 -0
- {django_admin_react-0.2.0a3 → django_admin_react-0.2.0a5}/django_admin_react/api/views/bulk.py +0 -0
- {django_admin_react-0.2.0a3 → django_admin_react-0.2.0a5}/django_admin_react/api/views/delete_preview.py +0 -0
- {django_admin_react-0.2.0a3 → django_admin_react-0.2.0a5}/django_admin_react/api/views/destroy.py +0 -0
- {django_admin_react-0.2.0a3 → django_admin_react-0.2.0a5}/django_admin_react/api/views/schema.py +0 -0
- {django_admin_react-0.2.0a3 → django_admin_react-0.2.0a5}/django_admin_react/apps.py +0 -0
- {django_admin_react-0.2.0a3 → django_admin_react-0.2.0a5}/django_admin_react/audit.py +0 -0
- {django_admin_react-0.2.0a3 → django_admin_react-0.2.0a5}/django_admin_react/conf.py +0 -0
- {django_admin_react-0.2.0a3 → django_admin_react-0.2.0a5}/django_admin_react/pwa.py +0 -0
- {django_admin_react-0.2.0a3 → django_admin_react-0.2.0a5}/django_admin_react/templates/admin_react/README.md +0 -0
- {django_admin_react-0.2.0a3 → django_admin_react-0.2.0a5}/django_admin_react/templates/admin_react/login.html +0 -0
- {django_admin_react-0.2.0a3 → django_admin_react-0.2.0a5}/django_admin_react/templates/admin_react/sw.js +0 -0
- {django_admin_react-0.2.0a3 → django_admin_react-0.2.0a5}/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.0a5
|
|
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
|
|
@@ -290,7 +299,7 @@ def apply_filters(queryset: QuerySet, model_admin: ModelAdmin, request: HttpRequ
|
|
|
290
299
|
if field_name is None:
|
|
291
300
|
continue
|
|
292
301
|
raw_value = request.GET.get(field_name)
|
|
293
|
-
if raw_value
|
|
302
|
+
if raw_value is None or raw_value == "":
|
|
294
303
|
continue
|
|
295
304
|
|
|
296
305
|
field = _safe_get_field(model, field_name)
|
|
@@ -27,20 +27,26 @@ 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
|
|
33
34
|
from django.http import HttpRequest
|
|
34
35
|
|
|
36
|
+
from django_admin_react.api.serializers import field_type_for
|
|
35
37
|
from django_admin_react.api.serializers import filter_sensitive
|
|
36
38
|
from django_admin_react.api.serializers import is_sensitive_field_name
|
|
37
39
|
from django_admin_react.api.serializers import label_for
|
|
40
|
+
from django_admin_react.api.serializers import safe_get_field
|
|
38
41
|
from django_admin_react.api.serializers import serialize_fk_value
|
|
39
42
|
from django_admin_react.api.serializers import serialize_value
|
|
40
43
|
|
|
41
44
|
|
|
42
45
|
def inlines_payload(
|
|
43
|
-
model_admin: ModelAdmin,
|
|
46
|
+
model_admin: ModelAdmin,
|
|
47
|
+
parent: Model,
|
|
48
|
+
request: HttpRequest,
|
|
49
|
+
admin_site: Any = None,
|
|
44
50
|
) -> list[dict[str, Any]]:
|
|
45
51
|
"""Build the ``inlines`` block of the detail response.
|
|
46
52
|
|
|
@@ -72,7 +78,7 @@ def inlines_payload(
|
|
|
72
78
|
out: list[dict[str, Any]] = []
|
|
73
79
|
inline_instances = _get_inline_instances(model_admin, parent, request)
|
|
74
80
|
for inline in inline_instances:
|
|
75
|
-
entry = _spec_for_inline(inline, parent, request)
|
|
81
|
+
entry = _spec_for_inline(inline, parent, request, admin_site)
|
|
76
82
|
if entry is not None:
|
|
77
83
|
out.append(entry)
|
|
78
84
|
return out
|
|
@@ -95,7 +101,10 @@ def _get_inline_instances(
|
|
|
95
101
|
|
|
96
102
|
|
|
97
103
|
def _spec_for_inline(
|
|
98
|
-
inline: InlineModelAdmin,
|
|
104
|
+
inline: InlineModelAdmin,
|
|
105
|
+
parent: Model,
|
|
106
|
+
request: HttpRequest,
|
|
107
|
+
admin_site: Any = None,
|
|
99
108
|
) -> dict[str, Any] | None:
|
|
100
109
|
"""Build one inline's metadata + rows payload.
|
|
101
110
|
|
|
@@ -124,7 +133,7 @@ def _spec_for_inline(
|
|
|
124
133
|
|
|
125
134
|
rows: list[dict[str, Any]] = []
|
|
126
135
|
if can_view:
|
|
127
|
-
rows = _rows_for_inline(inline, parent, fk_name, visible_fields, request)
|
|
136
|
+
rows = _rows_for_inline(inline, parent, fk_name, visible_fields, request, admin_site)
|
|
128
137
|
|
|
129
138
|
return {
|
|
130
139
|
"name": fk_name + "_set" if not hasattr(child_model, fk_name + "_set") else fk_name,
|
|
@@ -159,7 +168,9 @@ def _resolve_fk_name(inline: InlineModelAdmin, parent: Model) -> str | None:
|
|
|
159
168
|
if isinstance(field, ForeignKey):
|
|
160
169
|
related = field.related_model
|
|
161
170
|
if related is parent_class or (
|
|
162
|
-
related is not None
|
|
171
|
+
related is not None
|
|
172
|
+
and not isinstance(related, str)
|
|
173
|
+
and issubclass(parent_class, related)
|
|
163
174
|
):
|
|
164
175
|
return field.name
|
|
165
176
|
return None
|
|
@@ -181,7 +192,10 @@ def _visible_inline_fields(
|
|
|
181
192
|
visible = [
|
|
182
193
|
name
|
|
183
194
|
for name in declared
|
|
184
|
-
if name
|
|
195
|
+
if isinstance(name, str)
|
|
196
|
+
and name not in excluded
|
|
197
|
+
and name != fk_back
|
|
198
|
+
and not is_sensitive_field_name(name)
|
|
185
199
|
]
|
|
186
200
|
return filter_sensitive(visible)
|
|
187
201
|
|
|
@@ -192,19 +206,38 @@ def _fields_meta(
|
|
|
192
206
|
visible_fields: list[str],
|
|
193
207
|
request: HttpRequest,
|
|
194
208
|
) -> list[dict[str, Any]]:
|
|
195
|
-
"""Per-field metadata for the inline header
|
|
209
|
+
"""Per-field metadata for the inline header.
|
|
210
|
+
|
|
211
|
+
Carries ``type`` + ``required`` (in addition to ``name`` / ``label``
|
|
212
|
+
/ ``readonly``) so the SPA can render a *typed* input per inline
|
|
213
|
+
field in edit mode — the prerequisite for inline editing (#54
|
|
214
|
+
write-half UI). ``type`` reuses the same closed vocabulary
|
|
215
|
+
(``field_type_for``) the top-level detail descriptor uses, so the
|
|
216
|
+
frontend can route inline fields through the same ``FieldInput``
|
|
217
|
+
component. Additive — existing read-only consumers ignore the new
|
|
218
|
+
keys.
|
|
219
|
+
"""
|
|
196
220
|
readonly = set(inline.get_readonly_fields(request, None) or ())
|
|
197
221
|
out: list[dict[str, Any]] = []
|
|
198
222
|
for name in visible_fields:
|
|
223
|
+
label: Any
|
|
199
224
|
try:
|
|
200
225
|
label = label_for_field(name, child_model, inline)
|
|
201
226
|
except Exception: # pragma: no cover
|
|
202
227
|
label = name
|
|
228
|
+
model_field = safe_get_field(child_model, name)
|
|
229
|
+
field_type = field_type_for(model_field) if model_field is not None else "unsupported"
|
|
230
|
+
# ``required`` mirrors the form layer: a field is required when
|
|
231
|
+
# it is not ``blank``. ``safe_get_field`` returning ``None`` (a
|
|
232
|
+
# method-only ``list_display`` entry) → not required / unsupported.
|
|
233
|
+
required = bool(model_field is not None and not getattr(model_field, "blank", True))
|
|
203
234
|
out.append(
|
|
204
235
|
{
|
|
205
236
|
"name": name,
|
|
206
237
|
"label": str(label),
|
|
207
238
|
"readonly": name in readonly,
|
|
239
|
+
"type": field_type,
|
|
240
|
+
"required": required,
|
|
208
241
|
}
|
|
209
242
|
)
|
|
210
243
|
return out
|
|
@@ -216,6 +249,7 @@ def _rows_for_inline(
|
|
|
216
249
|
fk_name: str,
|
|
217
250
|
visible_fields: list[str],
|
|
218
251
|
request: HttpRequest,
|
|
252
|
+
admin_site: Any = None,
|
|
219
253
|
) -> list[dict[str, Any]]:
|
|
220
254
|
"""Fetch + serialize the child rows attached to ``parent``."""
|
|
221
255
|
try:
|
|
@@ -231,15 +265,43 @@ def _rows_for_inline(
|
|
|
231
265
|
model_field = inline.model._meta.get_field(name)
|
|
232
266
|
except Exception:
|
|
233
267
|
model_field = None
|
|
268
|
+
if model_field is None:
|
|
269
|
+
# No underlying model field: the inline lists a display
|
|
270
|
+
# method / callable (the `@admin.display def x(self, obj)`
|
|
271
|
+
# pattern, called with `obj`). Resolve via Django's own
|
|
272
|
+
# `lookup_field` (admin-first) so methods defined on the
|
|
273
|
+
# *inline admin* resolve, not just methods on the model
|
|
274
|
+
# instance. A naive `getattr(obj, name)` misses admin
|
|
275
|
+
# methods and returns None — the inline-row "—" bug
|
|
276
|
+
# (mirrors the detail-view fix in #232).
|
|
277
|
+
try:
|
|
278
|
+
_f, _attr, value = lookup_field(name, obj, inline)
|
|
279
|
+
except Exception:
|
|
280
|
+
# Guard the whole fallback: a readonly property can
|
|
281
|
+
# *raise* (not just be missing), and getattr's default
|
|
282
|
+
# only swallows AttributeError, so any other exception
|
|
283
|
+
# from the getter would 500 the parent detail (#275).
|
|
284
|
+
try:
|
|
285
|
+
value = getattr(obj, name, None)
|
|
286
|
+
if callable(value):
|
|
287
|
+
value = value()
|
|
288
|
+
except Exception:
|
|
289
|
+
value = None
|
|
290
|
+
fields_payload[name] = serialize_value(value)
|
|
291
|
+
continue
|
|
234
292
|
value = getattr(obj, name, None)
|
|
235
293
|
if isinstance(model_field, ForeignKey):
|
|
236
|
-
fields_payload[name] = serialize_fk_value(
|
|
294
|
+
fields_payload[name] = serialize_fk_value(
|
|
295
|
+
value, admin_site=admin_site, request=request
|
|
296
|
+
)
|
|
237
297
|
elif isinstance(model_field, ManyToManyField):
|
|
238
298
|
try:
|
|
239
|
-
related = list(value.all())
|
|
299
|
+
related = list(value.all()) if value is not None else []
|
|
240
300
|
except Exception:
|
|
241
301
|
related = []
|
|
242
|
-
fields_payload[name] = [
|
|
302
|
+
fields_payload[name] = [
|
|
303
|
+
serialize_fk_value(r, admin_site=admin_site, request=request) for r in related
|
|
304
|
+
]
|
|
243
305
|
else:
|
|
244
306
|
fields_payload[name] = serialize_value(value, field=model_field)
|
|
245
307
|
rows.append({"pk": obj.pk, "label": label_for(obj), "fields": fields_payload})
|
{django_admin_react-0.2.0a3 → django_admin_react-0.2.0a5}/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
|
+
}
|
{django_admin_react-0.2.0a3 → django_admin_react-0.2.0a5}/django_admin_react/api/serializers.py
RENAMED
|
@@ -196,11 +196,45 @@ def _serialize_range_value(value: Any, field: Field | None) -> dict[str, Any]:
|
|
|
196
196
|
}
|
|
197
197
|
|
|
198
198
|
|
|
199
|
-
def serialize_fk_value(
|
|
200
|
-
|
|
199
|
+
def serialize_fk_value(
|
|
200
|
+
value: Model | None,
|
|
201
|
+
*,
|
|
202
|
+
admin_site: Any = None,
|
|
203
|
+
request: Any = None,
|
|
204
|
+
) -> dict[str, Any] | None:
|
|
205
|
+
"""Serialize an FK as ``{"id": pk, "label": str(obj)}`` or ``None``.
|
|
206
|
+
|
|
207
|
+
When ``admin_site`` is provided **and** the related model is
|
|
208
|
+
registered on it, the envelope also carries
|
|
209
|
+
``to: {"app_label": <real>, "model_name": ...}`` so the SPA can
|
|
210
|
+
render the cell as a navigable link to the related object's detail
|
|
211
|
+
page (#184). The target is **omitted** when the related model isn't
|
|
212
|
+
registered — surfacing a link the detail endpoint would 404 on (and
|
|
213
|
+
leaking adjacency to an unregistered model) is the exact posture
|
|
214
|
+
#89 removed from filter descriptors. ``app_label`` is the real
|
|
215
|
+
``_meta.app_label`` the detail URL resolves against.
|
|
216
|
+
|
|
217
|
+
Issue #301 (least disclosure): when ``request`` is supplied, the
|
|
218
|
+
``to`` block is gated on the **target** model's per-user
|
|
219
|
+
``has_view_permission`` — we only advertise a navigable link the
|
|
220
|
+
user could actually follow. Otherwise the ``to`` block would leak
|
|
221
|
+
the existence + app/model identity of a registered model the user
|
|
222
|
+
cannot view (and the detail link would 404 anyway). The label is
|
|
223
|
+
still rendered unconditionally — the related *object* is visible in
|
|
224
|
+
the cell by design, matching Django's changelist. When ``request``
|
|
225
|
+
is absent (a direct / unit-test call), the registry-only #89
|
|
226
|
+
behaviour is preserved for backwards compatibility; every API view
|
|
227
|
+
passes ``request``.
|
|
228
|
+
"""
|
|
201
229
|
if value is None:
|
|
202
230
|
return None
|
|
203
|
-
|
|
231
|
+
out: dict[str, Any] = {"id": value.pk, "label": label_for(value)}
|
|
232
|
+
registry = getattr(admin_site, "_registry", {})
|
|
233
|
+
target_admin = registry.get(type(value)) if admin_site is not None else None
|
|
234
|
+
if target_admin is not None and (request is None or target_admin.has_view_permission(request)):
|
|
235
|
+
meta = value._meta
|
|
236
|
+
out["to"] = {"app_label": meta.app_label, "model_name": meta.model_name}
|
|
237
|
+
return out
|
|
204
238
|
|
|
205
239
|
|
|
206
240
|
def label_for(obj: Model) -> str:
|
|
@@ -235,9 +269,13 @@ def safe_get_field(model_or_instance: type[Model] | Model, name: str) -> Field |
|
|
|
235
269
|
``docs/architect-verdict-2026-05-26.md`` Condition A).
|
|
236
270
|
"""
|
|
237
271
|
try:
|
|
238
|
-
|
|
272
|
+
field = model_or_instance._meta.get_field(name)
|
|
239
273
|
except Exception:
|
|
240
274
|
return None
|
|
275
|
+
# ``get_field`` may also return reverse relations / generic FKs,
|
|
276
|
+
# which are not concrete ``Field``s. Callers want a real field or
|
|
277
|
+
# nothing (those names fall through to the callable/display path).
|
|
278
|
+
return field if isinstance(field, Field) else None
|
|
241
279
|
|
|
242
280
|
|
|
243
281
|
_TYPE_BY_INTERNAL: Final[dict[str, str]] = {
|
|
@@ -412,7 +450,10 @@ def field_metadata(
|
|
|
412
450
|
# readonly via writable_field_names; ``to`` still points at
|
|
413
451
|
# the target so the SPA can render the existing labels.
|
|
414
452
|
related = field.related_model
|
|
415
|
-
|
|
453
|
+
# ``related_model`` is the resolved model class by the time the
|
|
454
|
+
# admin is loaded; the ``"self"`` sentinel only exists during
|
|
455
|
+
# model definition. Guard it out so the type narrows cleanly.
|
|
456
|
+
if related is not None and not isinstance(related, str):
|
|
416
457
|
meta = related._meta
|
|
417
458
|
metadata["to"] = {"app_label": meta.app_label, "model_name": meta.model_name}
|
|
418
459
|
if getattr(field, "max_length", None):
|