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.
Files changed (53) hide show
  1. {django_admin_react-0.2.0a3 → django_admin_react-0.2.0a5}/PKG-INFO +37 -31
  2. {django_admin_react-0.2.0a3 → django_admin_react-0.2.0a5}/README.md +36 -30
  3. {django_admin_react-0.2.0a3 → django_admin_react-0.2.0a5}/django_admin_react/api/dates.py +9 -6
  4. {django_admin_react-0.2.0a3 → django_admin_react-0.2.0a5}/django_admin_react/api/filters.py +15 -6
  5. {django_admin_react-0.2.0a3 → django_admin_react-0.2.0a5}/django_admin_react/api/inlines.py +72 -10
  6. {django_admin_react-0.2.0a3 → django_admin_react-0.2.0a5}/django_admin_react/api/registry.py +39 -0
  7. {django_admin_react-0.2.0a3 → django_admin_react-0.2.0a5}/django_admin_react/api/serializers.py +46 -5
  8. {django_admin_react-0.2.0a3 → django_admin_react-0.2.0a5}/django_admin_react/api/urls.py +15 -6
  9. {django_admin_react-0.2.0a3 → django_admin_react-0.2.0a5}/django_admin_react/api/views/README.md +1 -0
  10. {django_admin_react-0.2.0a3 → django_admin_react-0.2.0a5}/django_admin_react/api/views/actions.py +24 -5
  11. {django_admin_react-0.2.0a3 → django_admin_react-0.2.0a5}/django_admin_react/api/views/create.py +1 -1
  12. {django_admin_react-0.2.0a3 → django_admin_react-0.2.0a5}/django_admin_react/api/views/create_form.py +6 -0
  13. {django_admin_react-0.2.0a3 → django_admin_react-0.2.0a5}/django_admin_react/api/views/detail.py +90 -12
  14. {django_admin_react-0.2.0a3 → django_admin_react-0.2.0a5}/django_admin_react/api/views/history.py +3 -1
  15. {django_admin_react-0.2.0a3 → django_admin_react-0.2.0a5}/django_admin_react/api/views/list.py +79 -8
  16. django_admin_react-0.2.0a5/django_admin_react/api/views/password.py +157 -0
  17. {django_admin_react-0.2.0a3 → django_admin_react-0.2.0a5}/django_admin_react/api/views/registry.py +3 -1
  18. {django_admin_react-0.2.0a3 → django_admin_react-0.2.0a5}/django_admin_react/api/views/update.py +1 -1
  19. {django_admin_react-0.2.0a3 → django_admin_react-0.2.0a5}/django_admin_react/api/writes.py +11 -3
  20. {django_admin_react-0.2.0a3 → django_admin_react-0.2.0a5}/django_admin_react/static/admin_react/.vite/manifest.json +2 -2
  21. django_admin_react-0.2.0a5/django_admin_react/static/admin_react/assets/index-BncyUUo8.js +8 -0
  22. django_admin_react-0.2.0a5/django_admin_react/static/admin_react/assets/index-Bt-X3hQW.css +1 -0
  23. {django_admin_react-0.2.0a3 → django_admin_react-0.2.0a5}/django_admin_react/static/admin_react/index.html +2 -2
  24. django_admin_react-0.2.0a5/django_admin_react/templates/README.md +27 -0
  25. {django_admin_react-0.2.0a3 → django_admin_react-0.2.0a5}/django_admin_react/templates/admin_react/index.html +11 -0
  26. {django_admin_react-0.2.0a3 → django_admin_react-0.2.0a5}/django_admin_react/views.py +5 -1
  27. {django_admin_react-0.2.0a3 → django_admin_react-0.2.0a5}/pyproject.toml +12 -1
  28. django_admin_react-0.2.0a3/django_admin_react/static/admin_react/assets/index-BOdTCQF7.js +0 -9
  29. django_admin_react-0.2.0a3/django_admin_react/static/admin_react/assets/index-BOdTCQF7.js.map +0 -1
  30. django_admin_react-0.2.0a3/django_admin_react/static/admin_react/assets/index-BgIZIHRa.css +0 -1
  31. {django_admin_react-0.2.0a3 → django_admin_react-0.2.0a5}/LICENSE +0 -0
  32. {django_admin_react-0.2.0a3 → django_admin_react-0.2.0a5}/django_admin_react/README.md +0 -0
  33. {django_admin_react-0.2.0a3 → django_admin_react-0.2.0a5}/django_admin_react/__init__.py +0 -0
  34. {django_admin_react-0.2.0a3 → django_admin_react-0.2.0a5}/django_admin_react/api/README.md +0 -0
  35. {django_admin_react-0.2.0a3 → django_admin_react-0.2.0a5}/django_admin_react/api/__init__.py +0 -0
  36. {django_admin_react-0.2.0a3 → django_admin_react-0.2.0a5}/django_admin_react/api/inlines_write.py +0 -0
  37. {django_admin_react-0.2.0a3 → django_admin_react-0.2.0a5}/django_admin_react/api/panels.py +0 -0
  38. {django_admin_react-0.2.0a3 → django_admin_react-0.2.0a5}/django_admin_react/api/permissions.py +0 -0
  39. {django_admin_react-0.2.0a3 → django_admin_react-0.2.0a5}/django_admin_react/api/views/__init__.py +0 -0
  40. {django_admin_react-0.2.0a3 → django_admin_react-0.2.0a5}/django_admin_react/api/views/auth.py +0 -0
  41. {django_admin_react-0.2.0a3 → django_admin_react-0.2.0a5}/django_admin_react/api/views/autocomplete.py +0 -0
  42. {django_admin_react-0.2.0a3 → django_admin_react-0.2.0a5}/django_admin_react/api/views/bulk.py +0 -0
  43. {django_admin_react-0.2.0a3 → django_admin_react-0.2.0a5}/django_admin_react/api/views/delete_preview.py +0 -0
  44. {django_admin_react-0.2.0a3 → django_admin_react-0.2.0a5}/django_admin_react/api/views/destroy.py +0 -0
  45. {django_admin_react-0.2.0a3 → django_admin_react-0.2.0a5}/django_admin_react/api/views/schema.py +0 -0
  46. {django_admin_react-0.2.0a3 → django_admin_react-0.2.0a5}/django_admin_react/apps.py +0 -0
  47. {django_admin_react-0.2.0a3 → django_admin_react-0.2.0a5}/django_admin_react/audit.py +0 -0
  48. {django_admin_react-0.2.0a3 → django_admin_react-0.2.0a5}/django_admin_react/conf.py +0 -0
  49. {django_admin_react-0.2.0a3 → django_admin_react-0.2.0a5}/django_admin_react/pwa.py +0 -0
  50. {django_admin_react-0.2.0a3 → django_admin_react-0.2.0a5}/django_admin_react/templates/admin_react/README.md +0 -0
  51. {django_admin_react-0.2.0a3 → django_admin_react-0.2.0a5}/django_admin_react/templates/admin_react/login.html +0 -0
  52. {django_admin_react-0.2.0a3 → django_admin_react-0.2.0a5}/django_admin_react/templates/admin_react/sw.js +0 -0
  53. {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.0a3
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
- | ![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
@@ -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 in (None, ""):
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, parent: Model, request: HttpRequest
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, parent: Model, request: HttpRequest
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 and issubclass(parent_class, related)
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 not in excluded and name != fk_back and not is_sensitive_field_name(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 — minimal shape."""
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(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] = [serialize_fk_value(r) for r in related]
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})
@@ -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
+ }
@@ -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(value: Model | None) -> dict[str, Any] | None:
200
- """Serialize an FK as ``{"id": pk, "label": str(obj)}`` or ``None``."""
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
- return {"id": value.pk, "label": label_for(value)}
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
- return model_or_instance._meta.get_field(name)
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
- if related is not None:
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):