django-admin-react 0.2.0a4__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.0a4 → django_admin_react-0.2.0a5}/PKG-INFO +37 -31
- {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a5}/README.md +36 -30
- {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a5}/django_admin_react/api/dates.py +9 -6
- {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a5}/django_admin_react/api/filters.py +15 -6
- {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a5}/django_admin_react/api/inlines.py +51 -9
- {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a5}/django_admin_react/api/registry.py +39 -0
- {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a5}/django_admin_react/api/serializers.py +30 -4
- {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a5}/django_admin_react/api/urls.py +15 -6
- {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a5}/django_admin_react/api/views/README.md +1 -0
- {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a5}/django_admin_react/api/views/actions.py +24 -5
- {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a5}/django_admin_react/api/views/create.py +1 -1
- {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a5}/django_admin_react/api/views/create_form.py +6 -0
- {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a5}/django_admin_react/api/views/detail.py +90 -12
- {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a5}/django_admin_react/api/views/history.py +3 -1
- {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a5}/django_admin_react/api/views/list.py +74 -7
- django_admin_react-0.2.0a5/django_admin_react/api/views/password.py +157 -0
- {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a5}/django_admin_react/api/views/registry.py +3 -1
- {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a5}/django_admin_react/api/views/update.py +1 -1
- {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a5}/django_admin_react/api/writes.py +11 -3
- {django_admin_react-0.2.0a4 → 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.0a4 → 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.0a4 → django_admin_react-0.2.0a5}/django_admin_react/templates/admin_react/index.html +7 -3
- {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a5}/django_admin_react/views.py +5 -1
- {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a5}/pyproject.toml +12 -1
- 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.0a5}/LICENSE +0 -0
- {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a5}/django_admin_react/README.md +0 -0
- {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a5}/django_admin_react/__init__.py +0 -0
- {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a5}/django_admin_react/api/README.md +0 -0
- {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a5}/django_admin_react/api/__init__.py +0 -0
- {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a5}/django_admin_react/api/inlines_write.py +0 -0
- {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a5}/django_admin_react/api/panels.py +0 -0
- {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a5}/django_admin_react/api/permissions.py +0 -0
- {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a5}/django_admin_react/api/views/__init__.py +0 -0
- {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a5}/django_admin_react/api/views/auth.py +0 -0
- {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a5}/django_admin_react/api/views/autocomplete.py +0 -0
- {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a5}/django_admin_react/api/views/bulk.py +0 -0
- {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a5}/django_admin_react/api/views/delete_preview.py +0 -0
- {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a5}/django_admin_react/api/views/destroy.py +0 -0
- {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a5}/django_admin_react/api/views/schema.py +0 -0
- {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a5}/django_admin_react/apps.py +0 -0
- {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a5}/django_admin_react/audit.py +0 -0
- {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a5}/django_admin_react/conf.py +0 -0
- {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a5}/django_admin_react/pwa.py +0 -0
- {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a5}/django_admin_react/templates/admin_react/README.md +0 -0
- {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a5}/django_admin_react/templates/admin_react/login.html +0 -0
- {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a5}/django_admin_react/templates/admin_react/sw.js +0 -0
- {django_admin_react-0.2.0a4 → 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,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
|
|
@@ -97,7 +101,10 @@ def _get_inline_instances(
|
|
|
97
101
|
|
|
98
102
|
|
|
99
103
|
def _spec_for_inline(
|
|
100
|
-
inline: InlineModelAdmin,
|
|
104
|
+
inline: InlineModelAdmin,
|
|
105
|
+
parent: Model,
|
|
106
|
+
request: HttpRequest,
|
|
107
|
+
admin_site: Any = None,
|
|
101
108
|
) -> dict[str, Any] | None:
|
|
102
109
|
"""Build one inline's metadata + rows payload.
|
|
103
110
|
|
|
@@ -126,7 +133,7 @@ def _spec_for_inline(
|
|
|
126
133
|
|
|
127
134
|
rows: list[dict[str, Any]] = []
|
|
128
135
|
if can_view:
|
|
129
|
-
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)
|
|
130
137
|
|
|
131
138
|
return {
|
|
132
139
|
"name": fk_name + "_set" if not hasattr(child_model, fk_name + "_set") else fk_name,
|
|
@@ -161,7 +168,9 @@ def _resolve_fk_name(inline: InlineModelAdmin, parent: Model) -> str | None:
|
|
|
161
168
|
if isinstance(field, ForeignKey):
|
|
162
169
|
related = field.related_model
|
|
163
170
|
if related is parent_class or (
|
|
164
|
-
related is not None
|
|
171
|
+
related is not None
|
|
172
|
+
and not isinstance(related, str)
|
|
173
|
+
and issubclass(parent_class, related)
|
|
165
174
|
):
|
|
166
175
|
return field.name
|
|
167
176
|
return None
|
|
@@ -183,7 +192,10 @@ def _visible_inline_fields(
|
|
|
183
192
|
visible = [
|
|
184
193
|
name
|
|
185
194
|
for name in declared
|
|
186
|
-
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)
|
|
187
199
|
]
|
|
188
200
|
return filter_sensitive(visible)
|
|
189
201
|
|
|
@@ -208,6 +220,7 @@ def _fields_meta(
|
|
|
208
220
|
readonly = set(inline.get_readonly_fields(request, None) or ())
|
|
209
221
|
out: list[dict[str, Any]] = []
|
|
210
222
|
for name in visible_fields:
|
|
223
|
+
label: Any
|
|
211
224
|
try:
|
|
212
225
|
label = label_for_field(name, child_model, inline)
|
|
213
226
|
except Exception: # pragma: no cover
|
|
@@ -236,6 +249,7 @@ def _rows_for_inline(
|
|
|
236
249
|
fk_name: str,
|
|
237
250
|
visible_fields: list[str],
|
|
238
251
|
request: HttpRequest,
|
|
252
|
+
admin_site: Any = None,
|
|
239
253
|
) -> list[dict[str, Any]]:
|
|
240
254
|
"""Fetch + serialize the child rows attached to ``parent``."""
|
|
241
255
|
try:
|
|
@@ -251,15 +265,43 @@ def _rows_for_inline(
|
|
|
251
265
|
model_field = inline.model._meta.get_field(name)
|
|
252
266
|
except Exception:
|
|
253
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
|
|
254
292
|
value = getattr(obj, name, None)
|
|
255
293
|
if isinstance(model_field, ForeignKey):
|
|
256
|
-
fields_payload[name] = serialize_fk_value(
|
|
294
|
+
fields_payload[name] = serialize_fk_value(
|
|
295
|
+
value, admin_site=admin_site, request=request
|
|
296
|
+
)
|
|
257
297
|
elif isinstance(model_field, ManyToManyField):
|
|
258
298
|
try:
|
|
259
|
-
related = list(value.all())
|
|
299
|
+
related = list(value.all()) if value is not None else []
|
|
260
300
|
except Exception:
|
|
261
301
|
related = []
|
|
262
|
-
fields_payload[name] = [
|
|
302
|
+
fields_payload[name] = [
|
|
303
|
+
serialize_fk_value(r, admin_site=admin_site, request=request) for r in related
|
|
304
|
+
]
|
|
263
305
|
else:
|
|
264
306
|
fields_payload[name] = serialize_value(value, field=model_field)
|
|
265
307
|
rows.append({"pk": obj.pk, "label": label_for(obj), "fields": fields_payload})
|
{django_admin_react-0.2.0a4 → 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.0a4 → django_admin_react-0.2.0a5}/django_admin_react/api/serializers.py
RENAMED
|
@@ -196,7 +196,12 @@ def _serialize_range_value(value: Any, field: Field | None) -> dict[str, Any]:
|
|
|
196
196
|
}
|
|
197
197
|
|
|
198
198
|
|
|
199
|
-
def serialize_fk_value(
|
|
199
|
+
def serialize_fk_value(
|
|
200
|
+
value: Model | None,
|
|
201
|
+
*,
|
|
202
|
+
admin_site: Any = None,
|
|
203
|
+
request: Any = None,
|
|
204
|
+
) -> dict[str, Any] | None:
|
|
200
205
|
"""Serialize an FK as ``{"id": pk, "label": str(obj)}`` or ``None``.
|
|
201
206
|
|
|
202
207
|
When ``admin_site`` is provided **and** the related model is
|
|
@@ -208,11 +213,25 @@ def serialize_fk_value(value: Model | None, *, admin_site: Any = None) -> dict[s
|
|
|
208
213
|
leaking adjacency to an unregistered model) is the exact posture
|
|
209
214
|
#89 removed from filter descriptors. ``app_label`` is the real
|
|
210
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``.
|
|
211
228
|
"""
|
|
212
229
|
if value is None:
|
|
213
230
|
return None
|
|
214
231
|
out: dict[str, Any] = {"id": value.pk, "label": label_for(value)}
|
|
215
|
-
|
|
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)):
|
|
216
235
|
meta = value._meta
|
|
217
236
|
out["to"] = {"app_label": meta.app_label, "model_name": meta.model_name}
|
|
218
237
|
return out
|
|
@@ -250,9 +269,13 @@ def safe_get_field(model_or_instance: type[Model] | Model, name: str) -> Field |
|
|
|
250
269
|
``docs/architect-verdict-2026-05-26.md`` Condition A).
|
|
251
270
|
"""
|
|
252
271
|
try:
|
|
253
|
-
|
|
272
|
+
field = model_or_instance._meta.get_field(name)
|
|
254
273
|
except Exception:
|
|
255
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
|
|
256
279
|
|
|
257
280
|
|
|
258
281
|
_TYPE_BY_INTERNAL: Final[dict[str, str]] = {
|
|
@@ -427,7 +450,10 @@ def field_metadata(
|
|
|
427
450
|
# readonly via writable_field_names; ``to`` still points at
|
|
428
451
|
# the target so the SPA can render the existing labels.
|
|
429
452
|
related = field.related_model
|
|
430
|
-
|
|
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):
|
|
431
457
|
meta = related._meta
|
|
432
458
|
metadata["to"] = {"app_label": meta.app_label, "model_name": meta.model_name}
|
|
433
459
|
if getattr(field, "max_length", None):
|
|
@@ -15,7 +15,7 @@ from __future__ import annotations
|
|
|
15
15
|
from typing import Any
|
|
16
16
|
|
|
17
17
|
from django.http import HttpRequest
|
|
18
|
-
from django.http import
|
|
18
|
+
from django.http import HttpResponseBase
|
|
19
19
|
from django.urls import path
|
|
20
20
|
from django.views.generic import View
|
|
21
21
|
|
|
@@ -32,6 +32,7 @@ from django_admin_react.api.views.destroy import DestroyView
|
|
|
32
32
|
from django_admin_react.api.views.detail import DetailView
|
|
33
33
|
from django_admin_react.api.views.history import HistoryView
|
|
34
34
|
from django_admin_react.api.views.list import ListView
|
|
35
|
+
from django_admin_react.api.views.password import SetPasswordView
|
|
35
36
|
from django_admin_react.api.views.registry import RegistryView
|
|
36
37
|
from django_admin_react.api.views.schema import SchemaView
|
|
37
38
|
from django_admin_react.api.views.update import UpdateView
|
|
@@ -47,11 +48,11 @@ class CollectionView(View):
|
|
|
47
48
|
|
|
48
49
|
http_method_names = ["get", "post"]
|
|
49
50
|
|
|
50
|
-
def get(self, request: HttpRequest, *args: Any, **kwargs: Any) ->
|
|
51
|
+
def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponseBase:
|
|
51
52
|
"""Forward GET to ``ListView`` (contract §3)."""
|
|
52
53
|
return ListView.as_view()(request, *args, **kwargs)
|
|
53
54
|
|
|
54
|
-
def post(self, request: HttpRequest, *args: Any, **kwargs: Any) ->
|
|
55
|
+
def post(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponseBase:
|
|
55
56
|
"""Forward POST to ``CreateView`` (contract §5.1)."""
|
|
56
57
|
return CreateView.as_view()(request, *args, **kwargs)
|
|
57
58
|
|
|
@@ -66,15 +67,15 @@ class InstanceView(View):
|
|
|
66
67
|
|
|
67
68
|
http_method_names = ["get", "patch", "delete"]
|
|
68
69
|
|
|
69
|
-
def get(self, request: HttpRequest, *args: Any, **kwargs: Any) ->
|
|
70
|
+
def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponseBase:
|
|
70
71
|
"""Forward GET to ``DetailView`` (contract §4)."""
|
|
71
72
|
return DetailView.as_view()(request, *args, **kwargs)
|
|
72
73
|
|
|
73
|
-
def patch(self, request: HttpRequest, *args: Any, **kwargs: Any) ->
|
|
74
|
+
def patch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponseBase:
|
|
74
75
|
"""Forward PATCH to ``UpdateView`` (contract §5.2)."""
|
|
75
76
|
return UpdateView.as_view()(request, *args, **kwargs)
|
|
76
77
|
|
|
77
|
-
def delete(self, request: HttpRequest, *args: Any, **kwargs: Any) ->
|
|
78
|
+
def delete(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponseBase:
|
|
78
79
|
"""Forward DELETE to ``DestroyView`` (contract §5.3)."""
|
|
79
80
|
return DestroyView.as_view()(request, *args, **kwargs)
|
|
80
81
|
|
|
@@ -147,6 +148,14 @@ urlpatterns: list = [
|
|
|
147
148
|
DeletePreviewView.as_view(),
|
|
148
149
|
name="delete_preview",
|
|
149
150
|
),
|
|
151
|
+
# Password set/change sub-resource (#252) — UserAdmin parity. Literal
|
|
152
|
+
# ``password`` segment must precede the ``<pk>`` instance route below.
|
|
153
|
+
# 404s for any model whose admin has no password-change form.
|
|
154
|
+
path(
|
|
155
|
+
"<str:app_label>/<str:model_name>/<str:pk>/password/",
|
|
156
|
+
SetPasswordView.as_view(),
|
|
157
|
+
name="set_password",
|
|
158
|
+
),
|
|
150
159
|
path(
|
|
151
160
|
"<str:app_label>/<str:model_name>/<str:pk>/",
|
|
152
161
|
InstanceView.as_view(),
|
{django_admin_react-0.2.0a4 → django_admin_react-0.2.0a5}/django_admin_react/api/views/README.md
RENAMED
|
@@ -10,6 +10,7 @@ One module per endpoint, mirroring [`/docs/api-contract.md`](../../../docs/api-c
|
|
|
10
10
|
| `create.py` | `POST /api/v1/<app>/<model>/` | #5 |
|
|
11
11
|
| `update.py` | `PATCH /api/v1/<app>/<model>/<pk>/` | #5 |
|
|
12
12
|
| `delete.py` | `DELETE /api/v1/<app>/<model>/<pk>/` | #5 |
|
|
13
|
+
| `password.py` | `POST /api/v1/<app>/<model>/<pk>/password/` | #252 |
|
|
13
14
|
|
|
14
15
|
Each view must:
|
|
15
16
|
|