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.
Files changed (54) hide show
  1. {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a6}/PKG-INFO +37 -31
  2. {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a6}/README.md +36 -30
  3. {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a6}/django_admin_react/api/dates.py +9 -6
  4. {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a6}/django_admin_react/api/filters.py +44 -10
  5. {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a6}/django_admin_react/api/inlines.py +79 -9
  6. {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a6}/django_admin_react/api/inlines_write.py +15 -0
  7. {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a6}/django_admin_react/api/registry.py +39 -0
  8. {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a6}/django_admin_react/api/serializers.py +30 -4
  9. {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a6}/django_admin_react/api/urls.py +15 -6
  10. {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a6}/django_admin_react/api/views/README.md +1 -0
  11. {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a6}/django_admin_react/api/views/actions.py +24 -5
  12. {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a6}/django_admin_react/api/views/bulk.py +37 -4
  13. django_admin_react-0.2.0a6/django_admin_react/api/views/create.py +213 -0
  14. {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a6}/django_admin_react/api/views/create_form.py +43 -0
  15. {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a6}/django_admin_react/api/views/detail.py +110 -20
  16. {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a6}/django_admin_react/api/views/history.py +3 -1
  17. {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a6}/django_admin_react/api/views/list.py +139 -9
  18. django_admin_react-0.2.0a6/django_admin_react/api/views/password.py +161 -0
  19. {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a6}/django_admin_react/api/views/registry.py +3 -1
  20. {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a6}/django_admin_react/api/views/schema.py +20 -0
  21. {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a6}/django_admin_react/api/views/update.py +81 -32
  22. {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a6}/django_admin_react/api/writes.py +38 -3
  23. {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a6}/django_admin_react/conf.py +9 -0
  24. {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a6}/django_admin_react/static/admin_react/.vite/manifest.json +2 -2
  25. django_admin_react-0.2.0a6/django_admin_react/static/admin_react/assets/index-Beowap5h.css +1 -0
  26. django_admin_react-0.2.0a6/django_admin_react/static/admin_react/assets/index-CgWOpEY8.js +8 -0
  27. {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a6}/django_admin_react/static/admin_react/index.html +2 -2
  28. django_admin_react-0.2.0a6/django_admin_react/templates/README.md +27 -0
  29. {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a6}/django_admin_react/templates/admin_react/index.html +15 -3
  30. {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a6}/django_admin_react/views.py +28 -1
  31. {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a6}/pyproject.toml +12 -1
  32. django_admin_react-0.2.0a4/django_admin_react/api/views/create.py +0 -143
  33. django_admin_react-0.2.0a4/django_admin_react/static/admin_react/assets/index-Cf63Q57m.css +0 -1
  34. django_admin_react-0.2.0a4/django_admin_react/static/admin_react/assets/index-Ch1wOBLJ.js +0 -9
  35. django_admin_react-0.2.0a4/django_admin_react/static/admin_react/assets/index-Ch1wOBLJ.js.map +0 -1
  36. {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a6}/LICENSE +0 -0
  37. {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a6}/django_admin_react/README.md +0 -0
  38. {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a6}/django_admin_react/__init__.py +0 -0
  39. {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a6}/django_admin_react/api/README.md +0 -0
  40. {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a6}/django_admin_react/api/__init__.py +0 -0
  41. {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a6}/django_admin_react/api/panels.py +0 -0
  42. {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a6}/django_admin_react/api/permissions.py +0 -0
  43. {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a6}/django_admin_react/api/views/__init__.py +0 -0
  44. {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a6}/django_admin_react/api/views/auth.py +0 -0
  45. {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a6}/django_admin_react/api/views/autocomplete.py +0 -0
  46. {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a6}/django_admin_react/api/views/delete_preview.py +0 -0
  47. {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a6}/django_admin_react/api/views/destroy.py +0 -0
  48. {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a6}/django_admin_react/apps.py +0 -0
  49. {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a6}/django_admin_react/audit.py +0 -0
  50. {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a6}/django_admin_react/pwa.py +0 -0
  51. {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a6}/django_admin_react/templates/admin_react/README.md +0 -0
  52. {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a6}/django_admin_react/templates/admin_react/login.html +0 -0
  53. {django_admin_react-0.2.0a4 → django_admin_react-0.2.0a6}/django_admin_react/templates/admin_react/sw.js +0 -0
  54. {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.0a4
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
- | ![Sign in](docs/screenshots/01-spa-login.png) | ![Registry](docs/screenshots/02-spa-registry.png) |
85
+ | ![Sign in](https://raw.githubusercontent.com/MartinCastroAlvarez/django-admin-react/main/docs/screenshots/01-spa-login.png) | ![Registry](https://raw.githubusercontent.com/MartinCastroAlvarez/django-admin-react/main/docs/screenshots/02-spa-registry.png) |
86
86
 
87
87
  | List view (`list_display` + search) | Detail view |
88
88
  | ------------------------------------------------------- | ---------------------------------------------------- |
89
- | ![List](docs/screenshots/03-spa-list.png) | ![Detail](docs/screenshots/05-spa-detail.png) |
89
+ | ![List](https://raw.githubusercontent.com/MartinCastroAlvarez/django-admin-react/main/docs/screenshots/03-spa-list.png) | ![Detail](https://raw.githubusercontent.com/MartinCastroAlvarez/django-admin-react/main/docs/screenshots/05-spa-detail.png) |
90
90
 
91
91
  | Mobile (375 px) | API: `GET /api/v1/registry/` |
92
92
  | ---------------------------------------------------------- | ---------------------------------------------------------- |
93
- | ![Mobile](docs/screenshots/04-spa-list-mobile.png) | ![Registry JSON](docs/screenshots/06-registry-api-json.png) |
93
+ | ![Mobile](https://raw.githubusercontent.com/MartinCastroAlvarez/django-admin-react/main/docs/screenshots/04-spa-list-mobile.png) | ![Registry JSON](https://raw.githubusercontent.com/MartinCastroAlvarez/django-admin-react/main/docs/screenshots/06-registry-api-json.png) |
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 (v0.1.0-alpha)
414
+ ## Feature status (alpha — currently `0.2.0a*` on PyPI)
415
415
 
416
- | Surface | Status |
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 | ✅ Backend + SPA contract |
419
- | `list_display`, `sortable_by`, `search_fields` | ✅ Backend + SPA contract |
420
- | `list_filter` (boolean / choice / FK / date / Simple) | ✅ Backend; SPA implementation pending |
421
- | `date_hierarchy` | ✅ Backend; SPA implementation pending |
422
- | `list_editable` + bulk PATCH | ✅ Backend; SPA implementation pending |
423
- | `actions` (custom + bulk runner) | ✅ Backend; SPA implementation pending |
424
- | `autocomplete_fields` / `raw_id_fields` | ✅ Backend + SPA contract |
425
- | `ManyToManyField` read + write | ✅ Backend; SPA implementation pending |
426
- | `inlines` (TabularInline / StackedInline) — read | Backend; SPA implementation pending |
427
- | `inlines` — write (formsets) | 🟡 Tracked in [#54](https://github.com/MartinCastroAlvarez/django-admin-react/issues/54) |
428
- | `FileField` / `ImageField` — read | Backend + SPA contract |
429
- | `FileField` / `ImageField` — multipart upload | 🟡 Tracked in [#57](https://github.com/MartinCastroAlvarez/django-admin-react/issues/57) |
430
- | `JSONField` / `ArrayField` / range types | Backend |
431
- | `register_field_type` + per-model SPA extension hook | ✅ Backend + extension contract |
432
- | Session-expiry re-login modal | Wire contract; SPA implementation pending |
433
- | OpenAPI 3.1 schema at `/api/v1/schema/` | ✅ Backend |
434
- | Dark mode (no-flash server-side resolution) | 🟡 UX contract; tracked in [#84](https://github.com/MartinCastroAlvarez/django-admin-react/issues/84) |
435
- | Mobile creative patterns (FAB / bottom-sheet / swipe) | 🟡 UX contract; tracked in [#85](https://github.com/MartinCastroAlvarez/django-admin-react/issues/85) |
436
- | PWA (manifest + service worker + cache-on-logout) | 🟡 UX contract; tracked in [#86](https://github.com/MartinCastroAlvarez/django-admin-react/issues/86) |
437
-
438
- Status meanings: ships in the current alpha; 🟡 contract or
439
- backend lands in the alpha, SPA implementation in flight. See
440
- [`ACCEPTANCE.md`](ACCEPTANCE.md) for the full criterion-by-criterion
441
- list and [the issue tracker](https://github.com/MartinCastroAlvarez/django-admin-react/issues)
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
- | ![Sign in](docs/screenshots/01-spa-login.png) | ![Registry](docs/screenshots/02-spa-registry.png) |
54
+ | ![Sign in](https://raw.githubusercontent.com/MartinCastroAlvarez/django-admin-react/main/docs/screenshots/01-spa-login.png) | ![Registry](https://raw.githubusercontent.com/MartinCastroAlvarez/django-admin-react/main/docs/screenshots/02-spa-registry.png) |
55
55
 
56
56
  | List view (`list_display` + search) | Detail view |
57
57
  | ------------------------------------------------------- | ---------------------------------------------------- |
58
- | ![List](docs/screenshots/03-spa-list.png) | ![Detail](docs/screenshots/05-spa-detail.png) |
58
+ | ![List](https://raw.githubusercontent.com/MartinCastroAlvarez/django-admin-react/main/docs/screenshots/03-spa-list.png) | ![Detail](https://raw.githubusercontent.com/MartinCastroAlvarez/django-admin-react/main/docs/screenshots/05-spa-detail.png) |
59
59
 
60
60
  | Mobile (375 px) | API: `GET /api/v1/registry/` |
61
61
  | ---------------------------------------------------------- | ---------------------------------------------------------- |
62
- | ![Mobile](docs/screenshots/04-spa-list-mobile.png) | ![Registry JSON](docs/screenshots/06-registry-api-json.png) |
62
+ | ![Mobile](https://raw.githubusercontent.com/MartinCastroAlvarez/django-admin-react/main/docs/screenshots/04-spa-list-mobile.png) | ![Registry JSON](https://raw.githubusercontent.com/MartinCastroAlvarez/django-admin-react/main/docs/screenshots/06-registry-api-json.png) |
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 (v0.1.0-alpha)
383
+ ## Feature status (alpha — currently `0.2.0a*` on PyPI)
384
384
 
385
- | Surface | Status |
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 | ✅ Backend + SPA contract |
388
- | `list_display`, `sortable_by`, `search_fields` | ✅ Backend + SPA contract |
389
- | `list_filter` (boolean / choice / FK / date / Simple) | ✅ Backend; SPA implementation pending |
390
- | `date_hierarchy` | ✅ Backend; SPA implementation pending |
391
- | `list_editable` + bulk PATCH | ✅ Backend; SPA implementation pending |
392
- | `actions` (custom + bulk runner) | ✅ Backend; SPA implementation pending |
393
- | `autocomplete_fields` / `raw_id_fields` | ✅ Backend + SPA contract |
394
- | `ManyToManyField` read + write | ✅ Backend; SPA implementation pending |
395
- | `inlines` (TabularInline / StackedInline) — read | Backend; SPA implementation pending |
396
- | `inlines` — write (formsets) | 🟡 Tracked in [#54](https://github.com/MartinCastroAlvarez/django-admin-react/issues/54) |
397
- | `FileField` / `ImageField` — read | Backend + SPA contract |
398
- | `FileField` / `ImageField` — multipart upload | 🟡 Tracked in [#57](https://github.com/MartinCastroAlvarez/django-admin-react/issues/57) |
399
- | `JSONField` / `ArrayField` / range types | Backend |
400
- | `register_field_type` + per-model SPA extension hook | ✅ Backend + extension contract |
401
- | Session-expiry re-login modal | Wire contract; SPA implementation pending |
402
- | OpenAPI 3.1 schema at `/api/v1/schema/` | ✅ Backend |
403
- | Dark mode (no-flash server-side resolution) | 🟡 UX contract; tracked in [#84](https://github.com/MartinCastroAlvarez/django-admin-react/issues/84) |
404
- | Mobile creative patterns (FAB / bottom-sheet / swipe) | 🟡 UX contract; tracked in [#85](https://github.com/MartinCastroAlvarez/django-admin-react/issues/85) |
405
- | PWA (manifest + service worker + cache-on-logout) | 🟡 UX contract; tracked in [#86](https://github.com/MartinCastroAlvarez/django-admin-react/issues/86) |
406
-
407
- Status meanings: ships in the current alpha; 🟡 contract or
408
- backend lands in the alpha, SPA implementation in flight. See
409
- [`ACCEPTANCE.md`](ACCEPTANCE.md) for the full criterion-by-criterion
410
- list and [the issue tracker](https://github.com/MartinCastroAlvarez/django-admin-react/issues)
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
- if active.get("year") is not None:
102
- lookups[f"{field}__year"] = active["year"]
103
- if active.get("month") is not None:
104
- lookups[f"{field}__month"] = active["month"]
105
- if active.get("day") is not None:
106
- lookups[f"{field}__day"] = active["day"]
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
- return model._meta.get_field(name)
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
- meta = related._meta if related is not None else None
123
- if related is None or meta is None:
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 = related._default_manager.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 in (None, ""):
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, parent: Model, request: HttpRequest
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, parent: Model, request: HttpRequest
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 and issubclass(parent_class, related)
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 not in excluded and name != fk_back and not is_sensitive_field_name(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(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] = [serialize_fk_value(r) for r in related]
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})
@@ -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
 
@@ -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
+ }