django-admin-react 0.2.0a2__tar.gz → 0.2.0a4__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 (52) hide show
  1. {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a4}/PKG-INFO +1 -1
  2. {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a4}/django_admin_react/api/dates.py +1 -5
  3. {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a4}/django_admin_react/api/filters.py +5 -1
  4. {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a4}/django_admin_react/api/inlines.py +24 -10
  5. {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a4}/django_admin_react/api/serializers.py +19 -5
  6. {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a4}/django_admin_react/api/urls.py +9 -0
  7. {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a4}/django_admin_react/api/views/actions.py +15 -1
  8. {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a4}/django_admin_react/api/views/autocomplete.py +2 -5
  9. django_admin_react-0.2.0a4/django_admin_react/api/views/create_form.py +96 -0
  10. {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a4}/django_admin_react/api/views/list.py +8 -4
  11. {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a4}/django_admin_react/api/views/schema.py +4 -14
  12. {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a4}/django_admin_react/conf.py +32 -0
  13. django_admin_react-0.2.0a4/django_admin_react/pwa.py +152 -0
  14. {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a4}/django_admin_react/static/admin_react/.vite/manifest.json +2 -2
  15. django_admin_react-0.2.0a4/django_admin_react/static/admin_react/assets/index-Cf63Q57m.css +1 -0
  16. django_admin_react-0.2.0a4/django_admin_react/static/admin_react/assets/index-Ch1wOBLJ.js +9 -0
  17. django_admin_react-0.2.0a4/django_admin_react/static/admin_react/assets/index-Ch1wOBLJ.js.map +1 -0
  18. {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a4}/django_admin_react/static/admin_react/index.html +2 -2
  19. {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a4}/django_admin_react/templates/admin_react/index.html +7 -0
  20. django_admin_react-0.2.0a4/django_admin_react/templates/admin_react/sw.js +136 -0
  21. {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a4}/django_admin_react/urls.py +8 -0
  22. {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a4}/django_admin_react/views.py +26 -3
  23. {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a4}/pyproject.toml +1 -1
  24. django_admin_react-0.2.0a2/django_admin_react/static/admin_react/assets/index-BE3CZdBI.js +0 -9
  25. django_admin_react-0.2.0a2/django_admin_react/static/admin_react/assets/index-BE3CZdBI.js.map +0 -1
  26. django_admin_react-0.2.0a2/django_admin_react/static/admin_react/assets/index-xZLX3uph.css +0 -1
  27. {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a4}/LICENSE +0 -0
  28. {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a4}/README.md +0 -0
  29. {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a4}/django_admin_react/README.md +0 -0
  30. {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a4}/django_admin_react/__init__.py +0 -0
  31. {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a4}/django_admin_react/api/README.md +0 -0
  32. {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a4}/django_admin_react/api/__init__.py +0 -0
  33. {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a4}/django_admin_react/api/inlines_write.py +0 -0
  34. {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a4}/django_admin_react/api/panels.py +0 -0
  35. {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a4}/django_admin_react/api/permissions.py +0 -0
  36. {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a4}/django_admin_react/api/registry.py +0 -0
  37. {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a4}/django_admin_react/api/views/README.md +0 -0
  38. {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a4}/django_admin_react/api/views/__init__.py +0 -0
  39. {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a4}/django_admin_react/api/views/auth.py +0 -0
  40. {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a4}/django_admin_react/api/views/bulk.py +0 -0
  41. {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a4}/django_admin_react/api/views/create.py +0 -0
  42. {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a4}/django_admin_react/api/views/delete_preview.py +0 -0
  43. {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a4}/django_admin_react/api/views/destroy.py +0 -0
  44. {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a4}/django_admin_react/api/views/detail.py +0 -0
  45. {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a4}/django_admin_react/api/views/history.py +0 -0
  46. {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a4}/django_admin_react/api/views/registry.py +0 -0
  47. {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a4}/django_admin_react/api/views/update.py +0 -0
  48. {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a4}/django_admin_react/api/writes.py +0 -0
  49. {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a4}/django_admin_react/apps.py +0 -0
  50. {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a4}/django_admin_react/audit.py +0 -0
  51. {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a4}/django_admin_react/templates/admin_react/README.md +0 -0
  52. {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a4}/django_admin_react/templates/admin_react/login.html +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: django-admin-react
3
- Version: 0.2.0a2
3
+ Version: 0.2.0a4
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
@@ -143,11 +143,7 @@ def build_buckets(
143
143
  .annotate(_count=Count("pk"))
144
144
  .order_by("_bucket")
145
145
  )
146
- return [
147
- {"value": r["_bucket"], "count": r["_count"]}
148
- for r in rows
149
- if r["_bucket"] is not None
150
- ]
146
+ return [{"value": r["_bucket"], "count": r["_count"]} for r in rows if r["_bucket"] is not None]
151
147
 
152
148
 
153
149
  def date_hierarchy_payload(
@@ -34,6 +34,7 @@ Hard rules (`SECURITY.md` §3):
34
34
 
35
35
  from __future__ import annotations
36
36
 
37
+ import logging
37
38
  from typing import Any
38
39
 
39
40
  from django.contrib.admin import SimpleListFilter
@@ -49,6 +50,8 @@ from django.http import HttpRequest
49
50
 
50
51
  from django_admin_react.api.serializers import is_sensitive_field_name
51
52
 
53
+ logger = logging.getLogger(__name__)
54
+
52
55
  # PM ruling (Q-PM-03): FK filters in v1 surface up to ≤ 25 options
53
56
  # inline; larger target tables defer to a follow-up that combines
54
57
  # list_filter with autocomplete (#59). Keep the cap explicit so a
@@ -273,7 +276,8 @@ def apply_filters(queryset: QuerySet, model_admin: ModelAdmin, request: HttpRequ
273
276
  if filter_cls is not None and issubclass(filter_cls, SimpleListFilter):
274
277
  try:
275
278
  instance = filter_cls(request, request.GET.copy(), model_admin.model, model_admin)
276
- except Exception: # pragma: no cover
279
+ except Exception: # pragma: no cover - skip a misbehaving consumer filter
280
+ logger.debug("Skipping list_filter %r: instantiation failed", entry, exc_info=True)
277
281
  continue
278
282
  try:
279
283
  narrowed = instance.queryset(request, queryset)
@@ -32,9 +32,11 @@ from django.db.models import ManyToManyField
32
32
  from django.db.models import Model
33
33
  from django.http import HttpRequest
34
34
 
35
+ from django_admin_react.api.serializers import field_type_for
35
36
  from django_admin_react.api.serializers import filter_sensitive
36
37
  from django_admin_react.api.serializers import is_sensitive_field_name
37
38
  from django_admin_react.api.serializers import label_for
39
+ from django_admin_react.api.serializers import safe_get_field
38
40
  from django_admin_react.api.serializers import serialize_fk_value
39
41
  from django_admin_react.api.serializers import serialize_value
40
42
 
@@ -124,9 +126,7 @@ def _spec_for_inline(
124
126
 
125
127
  rows: list[dict[str, Any]] = []
126
128
  if can_view:
127
- rows = _rows_for_inline(
128
- inline, parent, fk_name, visible_fields, request
129
- )
129
+ rows = _rows_for_inline(inline, parent, fk_name, visible_fields, request)
130
130
 
131
131
  return {
132
132
  "name": fk_name + "_set" if not hasattr(child_model, fk_name + "_set") else fk_name,
@@ -183,9 +183,7 @@ def _visible_inline_fields(
183
183
  visible = [
184
184
  name
185
185
  for name in declared
186
- if name not in excluded
187
- and name != fk_back
188
- and not is_sensitive_field_name(name)
186
+ if name not in excluded and name != fk_back and not is_sensitive_field_name(name)
189
187
  ]
190
188
  return filter_sensitive(visible)
191
189
 
@@ -196,7 +194,17 @@ def _fields_meta(
196
194
  visible_fields: list[str],
197
195
  request: HttpRequest,
198
196
  ) -> list[dict[str, Any]]:
199
- """Per-field metadata for the inline header — minimal shape."""
197
+ """Per-field metadata for the inline header.
198
+
199
+ Carries ``type`` + ``required`` (in addition to ``name`` / ``label``
200
+ / ``readonly``) so the SPA can render a *typed* input per inline
201
+ field in edit mode — the prerequisite for inline editing (#54
202
+ write-half UI). ``type`` reuses the same closed vocabulary
203
+ (``field_type_for``) the top-level detail descriptor uses, so the
204
+ frontend can route inline fields through the same ``FieldInput``
205
+ component. Additive — existing read-only consumers ignore the new
206
+ keys.
207
+ """
200
208
  readonly = set(inline.get_readonly_fields(request, None) or ())
201
209
  out: list[dict[str, Any]] = []
202
210
  for name in visible_fields:
@@ -204,11 +212,19 @@ def _fields_meta(
204
212
  label = label_for_field(name, child_model, inline)
205
213
  except Exception: # pragma: no cover
206
214
  label = name
215
+ model_field = safe_get_field(child_model, name)
216
+ field_type = field_type_for(model_field) if model_field is not None else "unsupported"
217
+ # ``required`` mirrors the form layer: a field is required when
218
+ # it is not ``blank``. ``safe_get_field`` returning ``None`` (a
219
+ # method-only ``list_display`` entry) → not required / unsupported.
220
+ required = bool(model_field is not None and not getattr(model_field, "blank", True))
207
221
  out.append(
208
222
  {
209
223
  "name": name,
210
224
  "label": str(label),
211
225
  "readonly": name in readonly,
226
+ "type": field_type,
227
+ "required": required,
212
228
  }
213
229
  )
214
230
  return out
@@ -246,7 +262,5 @@ def _rows_for_inline(
246
262
  fields_payload[name] = [serialize_fk_value(r) for r in related]
247
263
  else:
248
264
  fields_payload[name] = serialize_value(value, field=model_field)
249
- rows.append(
250
- {"pk": obj.pk, "label": label_for(obj), "fields": fields_payload}
251
- )
265
+ rows.append({"pk": obj.pk, "label": label_for(obj), "fields": fields_payload})
252
266
  return rows
@@ -153,8 +153,7 @@ def _looks_like_range(value: Any) -> bool:
153
153
  dependency.
154
154
  """
155
155
  return all(
156
- hasattr(value, attr)
157
- for attr in ("lower", "upper", "lower_inc", "upper_inc", "isempty")
156
+ hasattr(value, attr) for attr in ("lower", "upper", "lower_inc", "upper_inc", "isempty")
158
157
  )
159
158
 
160
159
 
@@ -197,11 +196,26 @@ def _serialize_range_value(value: Any, field: Field | None) -> dict[str, Any]:
197
196
  }
198
197
 
199
198
 
200
- def serialize_fk_value(value: Model | None) -> dict[str, Any] | None:
201
- """Serialize an FK as ``{"id": pk, "label": str(obj)}`` or ``None``."""
199
+ def serialize_fk_value(value: Model | None, *, admin_site: Any = None) -> dict[str, Any] | None:
200
+ """Serialize an FK as ``{"id": pk, "label": str(obj)}`` or ``None``.
201
+
202
+ When ``admin_site`` is provided **and** the related model is
203
+ registered on it, the envelope also carries
204
+ ``to: {"app_label": <real>, "model_name": ...}`` so the SPA can
205
+ render the cell as a navigable link to the related object's detail
206
+ page (#184). The target is **omitted** when the related model isn't
207
+ registered — surfacing a link the detail endpoint would 404 on (and
208
+ leaking adjacency to an unregistered model) is the exact posture
209
+ #89 removed from filter descriptors. ``app_label`` is the real
210
+ ``_meta.app_label`` the detail URL resolves against.
211
+ """
202
212
  if value is None:
203
213
  return None
204
- return {"id": value.pk, "label": label_for(value)}
214
+ out: dict[str, Any] = {"id": value.pk, "label": label_for(value)}
215
+ if admin_site is not None and type(value) in getattr(admin_site, "_registry", {}):
216
+ meta = value._meta
217
+ out["to"] = {"app_label": meta.app_label, "model_name": meta.model_name}
218
+ return out
205
219
 
206
220
 
207
221
  def label_for(obj: Model) -> str:
@@ -26,6 +26,7 @@ from django_admin_react.api.views.auth import LogoutView
26
26
  from django_admin_react.api.views.autocomplete import AutocompleteView
27
27
  from django_admin_react.api.views.bulk import BulkUpdateView
28
28
  from django_admin_react.api.views.create import CreateView
29
+ from django_admin_react.api.views.create_form import AddFormView
29
30
  from django_admin_react.api.views.delete_preview import DeletePreviewView
30
31
  from django_admin_react.api.views.destroy import DestroyView
31
32
  from django_admin_react.api.views.detail import DetailView
@@ -110,6 +111,14 @@ urlpatterns: list = [
110
111
  BulkUpdateView.as_view(),
111
112
  name="bulk_update",
112
113
  ),
114
+ # Add-form schema — the create page's field descriptors for a NEW
115
+ # object. Literal ``add`` must precede the ``<pk>`` instance route
116
+ # below so it isn't swallowed as a pk.
117
+ path(
118
+ "<str:app_label>/<str:model_name>/add/",
119
+ AddFormView.as_view(),
120
+ name="add_form",
121
+ ),
113
122
  path(
114
123
  "<str:app_label>/<str:model_name>/",
115
124
  CollectionView.as_view(),
@@ -31,6 +31,7 @@ from __future__ import annotations
31
31
 
32
32
  from typing import Any
33
33
 
34
+ from django.contrib.admin.utils import model_format_dict
34
35
  from django.db import transaction
35
36
  from django.http import HttpRequest
36
37
  from django.http import HttpResponse
@@ -138,9 +139,22 @@ def actions_payload(model_admin: Any, request: HttpRequest) -> list[dict[str, An
138
139
  confirmation step regardless — this hint is a UX optimisation.
139
140
  """
140
141
  raw = model_admin.get_actions(request) or {}
142
+ # Django's built-in `delete_selected` (and any action whose
143
+ # `short_description` uses the admin's `%(verbose_name)s` /
144
+ # `%(verbose_name_plural)s` placeholders) ships a *format string*,
145
+ # not a finished label — Django interpolates it at render time via
146
+ # `model_format_dict(opts)`. Do the same here so the SPA shows
147
+ # "Delete selected files", never the raw "%(verbose_name_plural)s".
148
+ fmt = model_format_dict(model_admin.model._meta)
141
149
  out: list[dict[str, Any]] = []
142
150
  for name, (_callable, _resolved_name, description) in raw.items():
143
- label = str(description) if description else name
151
+ raw_label = str(description) if description else name
152
+ try:
153
+ label = raw_label % fmt
154
+ except (KeyError, ValueError, TypeError):
155
+ # Not a %-format string, or references a key we don't
156
+ # provide — surface the label verbatim rather than crashing.
157
+ label = raw_label
144
158
  requires_conf = "delete" in (label.lower() + " " + name.lower())
145
159
  out.append(
146
160
  {
@@ -91,8 +91,7 @@ class AutocompleteView(View):
91
91
 
92
92
  if not model_admin.search_fields:
93
93
  return bad_request(
94
- "The target admin does not declare search_fields; "
95
- "autocomplete is not available."
94
+ "The target admin does not declare search_fields; " "autocomplete is not available."
96
95
  )
97
96
 
98
97
  q = (request.GET.get("q") or "").strip()
@@ -101,9 +100,7 @@ class AutocompleteView(View):
101
100
 
102
101
  queryset = model_admin.get_queryset(request)
103
102
  if q:
104
- queryset, may_have_duplicates = model_admin.get_search_results(
105
- request, queryset, q
106
- )
103
+ queryset, may_have_duplicates = model_admin.get_search_results(request, queryset, q)
107
104
  if may_have_duplicates:
108
105
  queryset = queryset.distinct()
109
106
 
@@ -0,0 +1,96 @@
1
+ """``GET /api/v1/<app>/<model>/add/`` — the create-form schema.
2
+
3
+ The detail view (``/<pk>/``) needs an existing object; the SPA's
4
+ create page needs the same field descriptors + fieldsets for a *new*
5
+ object. This view builds that payload from an unsaved instance, the
6
+ add form (``get_form(request, obj=None, change=False)`` — exactly how
7
+ Django's add view builds it), and the read-visible field set.
8
+
9
+ It deliberately reuses the detail view's descriptor builders so the
10
+ field shape is byte-for-byte identical to what edit renders — the SPA
11
+ uses one ``FieldInput`` component for both.
12
+
13
+ Hard rules: staff gate (rule 1), model resolved through the registry
14
+ (rule 3), ``has_add_permission`` gate (rule 6 — create is gated on
15
+ add, not view), sensitive-name denylist applied (S-31).
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from typing import Any
21
+
22
+ from django.http import HttpRequest
23
+ from django.http import HttpResponse
24
+ from django.http import JsonResponse
25
+ from django.views.generic import View
26
+
27
+ from django_admin_react.api.permissions import forbidden_response
28
+ from django_admin_react.api.permissions import is_admin_user
29
+ from django_admin_react.api.registry import get_admin_site
30
+ from django_admin_react.api.registry import model_permissions
31
+ from django_admin_react.api.registry import resolve_model
32
+ from django_admin_react.api.views.detail import _descriptor_for
33
+ from django_admin_react.api.views.detail import _fieldsets_payload
34
+ from django_admin_react.api.views.detail import _visible_field_names
35
+ from django_admin_react.api.writes import not_found_response
36
+
37
+
38
+ class AddFormView(View):
39
+ """``GET /api/v1/<app_label>/<model_name>/add/`` — empty create form."""
40
+
41
+ http_method_names = ["get"]
42
+
43
+ def get(
44
+ self,
45
+ request: HttpRequest,
46
+ app_label: str,
47
+ model_name: str,
48
+ *args: Any,
49
+ **kwargs: Any,
50
+ ) -> HttpResponse:
51
+ admin_site = get_admin_site()
52
+ if not is_admin_user(request, admin_site=admin_site):
53
+ return forbidden_response(request)
54
+
55
+ resolved = resolve_model(admin_site, request, app_label, model_name)
56
+ if resolved is None:
57
+ return not_found_response()
58
+ model, model_admin = resolved
59
+
60
+ # Create is gated on add — not view. A user who can view but
61
+ # not add must not be handed an add form.
62
+ if not model_admin.has_add_permission(request):
63
+ return forbidden_response(request)
64
+
65
+ # Unsaved instance so descriptor builders have field defaults to
66
+ # read (FK → None, M2M → [] via the guards in _descriptor_for).
67
+ obj = model()
68
+
69
+ visible_names = _visible_field_names(model_admin, request, None)
70
+ readonly = set(model_admin.get_readonly_fields(request, None) or ())
71
+ # The ADD form — change=False, obj=None — exactly how Django's
72
+ # add view constructs it (``ModelAdmin._changeform_view`` with
73
+ # add=True passes change=False).
74
+ form = model_admin.get_form(request, obj=None, change=False)()
75
+
76
+ fields: dict[str, dict[str, Any]] = {}
77
+ for name in visible_names:
78
+ fields[name] = _descriptor_for(
79
+ model=model,
80
+ model_admin=model_admin,
81
+ obj=obj,
82
+ name=name,
83
+ form=form,
84
+ is_readonly=name in readonly,
85
+ )
86
+
87
+ payload = {
88
+ "app_label": model._meta.app_label,
89
+ "model_name": model._meta.model_name,
90
+ "permissions": model_permissions(model_admin, request),
91
+ "fieldsets": _fieldsets_payload(model_admin, request, None, visible_names),
92
+ "fields": fields,
93
+ }
94
+ response = JsonResponse(payload, status=200)
95
+ response["Cache-Control"] = "no-store"
96
+ return response
@@ -112,7 +112,10 @@ class ListView(View):
112
112
  list_display = list(model_admin.get_list_display(request))
113
113
  columns = _columns_payload(model_admin, list_display, request)
114
114
 
115
- results = [_row_for(obj, model_admin, list_display, request) for obj in queryset[start:end]]
115
+ results = [
116
+ _row_for(obj, model_admin, list_display, request, admin_site)
117
+ for obj in queryset[start:end]
118
+ ]
116
119
 
117
120
  body: dict[str, Any] = {
118
121
  "app_label": model._meta.app_label,
@@ -267,6 +270,7 @@ def _row_for(
267
270
  model_admin: ModelAdmin,
268
271
  list_display: list[str],
269
272
  request: HttpRequest,
273
+ admin_site: Any = None,
270
274
  ) -> dict[str, Any]:
271
275
  """Build one ``results[]`` entry for the list response.
272
276
 
@@ -283,11 +287,11 @@ def _row_for(
283
287
  _f, _attr, value = lookup_field(name, obj, model_admin)
284
288
  except Exception: # pragma: no cover — defensive
285
289
  value = ""
286
- fields[name] = _serialize_list_value(obj, name, value)
290
+ fields[name] = _serialize_list_value(obj, name, value, admin_site)
287
291
  return {"pk": obj.pk, "label": label_for(obj), "fields": fields}
288
292
 
289
293
 
290
- def _serialize_list_value(obj: Model, name: str, value: Any) -> Any:
294
+ def _serialize_list_value(obj: Model, name: str, value: Any, admin_site: Any = None) -> Any:
291
295
  """Serialize a single ``list_display`` cell.
292
296
 
293
297
  FK fields go through the FK envelope (``{"id", "label"}``);
@@ -300,5 +304,5 @@ def _serialize_list_value(obj: Model, name: str, value: Any) -> Any:
300
304
  """
301
305
  model_field = safe_get_field(obj, name)
302
306
  if isinstance(model_field, ForeignKey):
303
- return serialize_fk_value(value)
307
+ return serialize_fk_value(value, admin_site=admin_site)
304
308
  return serialize_value(value, field=model_field)
@@ -254,9 +254,7 @@ def _components() -> dict[str, Any]:
254
254
  "type": "array",
255
255
  "items": {"$ref": "#/components/schemas/ActionSpec"},
256
256
  },
257
- "date_hierarchy": {
258
- "$ref": "#/components/schemas/DateHierarchy"
259
- },
257
+ "date_hierarchy": {"$ref": "#/components/schemas/DateHierarchy"},
260
258
  "page": {"type": "integer"},
261
259
  "page_size": {"type": "integer"},
262
260
  "total": {"type": "integer"},
@@ -337,9 +335,7 @@ def _components() -> dict[str, Any]:
337
335
  },
338
336
  "fields": {
339
337
  "type": "object",
340
- "additionalProperties": {
341
- "$ref": "#/components/schemas/FieldDescriptor"
342
- },
338
+ "additionalProperties": {"$ref": "#/components/schemas/FieldDescriptor"},
343
339
  },
344
340
  },
345
341
  },
@@ -365,11 +361,7 @@ def _components() -> dict[str, Any]:
365
361
  "responses": {
366
362
  "Error": {
367
363
  "description": "Error envelope.",
368
- "content": {
369
- "application/json": {
370
- "schema": {"$ref": "#/components/schemas/Error"}
371
- }
372
- },
364
+ "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}},
373
365
  }
374
366
  },
375
367
  }
@@ -462,9 +454,7 @@ def _ok_response(schema_ref: str) -> dict[str, Any]:
462
454
  "200": {
463
455
  "description": "OK.",
464
456
  "content": {
465
- "application/json": {
466
- "schema": {"$ref": f"#/components/schemas/{schema_ref}"}
467
- }
457
+ "application/json": {"schema": {"$ref": f"#/components/schemas/{schema_ref}"}}
468
458
  },
469
459
  },
470
460
  "403": {"$ref": "#/components/responses/Error"},
@@ -41,6 +41,34 @@ DEFAULTS: dict[str, Any] = {
41
41
  # URL or a path under your ``STATIC_URL``.
42
42
  "BRAND_TITLE": None,
43
43
  "BRAND_LOGO_URL": None,
44
+ # ``REACT_LOGIN`` — opt-in React-rendered login (Issue #167).
45
+ # Default ``False`` keeps today's behavior: ``SpaIndexView``
46
+ # redirects anonymous / unauthorized users to Django's HTML login
47
+ # (or the package's own ``<mount>/login/`` page). When ``True``,
48
+ # the SPA shell is served to anonymous users (with the CSRF cookie
49
+ # set) so the React app can render its own login form, which POSTs
50
+ # to ``/api/v1/login/``. The auth *mechanism* is unchanged — still
51
+ # Django's ``authenticate``/``login`` behind the JSON endpoint
52
+ # (`api/views/auth.py`); only the UI surface differs. The shell
53
+ # carries no user data, so serving it to anonymous users discloses
54
+ # nothing the static bundle wouldn't, and every data API call still
55
+ # returns 403 until the user authenticates.
56
+ "REACT_LOGIN": False,
57
+ # PWA (Issue #86) — all optional; sane defaults make the manifest
58
+ # work with zero config. See ``django_admin_react/pwa.py`` +
59
+ # ``docs/ux/pwa.md``.
60
+ #
61
+ # ``PWA_NAME`` — installed-app name. ``None`` (default) falls
62
+ # back to the AdminSite ``site_header``, then
63
+ # ``"Django admin"``.
64
+ # ``PWA_SHORT_NAME`` — home-screen label. Defaults to ``"Admin"``.
65
+ # ``PWA_ICONS`` — list of ``{src, sizes, type[, purpose]}``
66
+ # dicts. ``None`` (default) uses the shipped
67
+ # 192/512/maskable set under
68
+ # ``static/dar/icons/``.
69
+ "PWA_NAME": None,
70
+ "PWA_SHORT_NAME": None,
71
+ "PWA_ICONS": None,
44
72
  }
45
73
 
46
74
 
@@ -58,6 +86,10 @@ class _PackageSettings:
58
86
  ENABLE_PROFILING: bool = DEFAULTS["ENABLE_PROFILING"]
59
87
  BRAND_TITLE: str | None = DEFAULTS["BRAND_TITLE"]
60
88
  BRAND_LOGO_URL: str | None = DEFAULTS["BRAND_LOGO_URL"]
89
+ REACT_LOGIN: bool = DEFAULTS["REACT_LOGIN"]
90
+ PWA_NAME: str | None = DEFAULTS["PWA_NAME"]
91
+ PWA_SHORT_NAME: str | None = DEFAULTS["PWA_SHORT_NAME"]
92
+ PWA_ICONS: list[dict[str, str]] | None = DEFAULTS["PWA_ICONS"]
61
93
 
62
94
 
63
95
  def _load() -> _PackageSettings:
@@ -0,0 +1,152 @@
1
+ """PWA surface: web app manifest + service worker (Issue #86).
2
+
3
+ Wire/UX contract: ``docs/ux/pwa.md``. The Security lane owns this
4
+ surface because its load-bearing properties are security ones:
5
+
6
+ - The **manifest** (``<mount>/web.manifest``) is served unauthenticated
7
+ (the install prompt fires before login) and is computed at request
8
+ time, but it carries **no per-user data** — only static/global values
9
+ (mount-derived ``start_url``/``scope``, the AdminSite header, icons,
10
+ theme colours from the client hint). An anonymous reader learns
11
+ nothing they couldn't get from the static bundle.
12
+ - The **service worker** (``<mount>/sw.js``) is served with
13
+ ``Service-Worker-Allowed: <mount>`` so its scope is exactly the mount
14
+ and **never** sibling Django views. It honours ``Cache-Control:
15
+ no-store`` (so the package's no-store API reads are never cached),
16
+ never caches non-GET requests (mutation safety), and exposes a
17
+ cache-purge message used on logout so read-cached payloads can't
18
+ outlive the session (``pwa.md`` §5 — defense-in-depth atop session
19
+ expiry).
20
+
21
+ Both views live **outside** ``api/`` because they're served at the
22
+ mount root, not under ``api/v1/``, and the manifest is intentionally
23
+ anonymous (unlike every API endpoint, which is staff-gated).
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ from typing import Any
29
+
30
+ from django.http import HttpRequest
31
+ from django.http import HttpResponse
32
+ from django.http import JsonResponse
33
+ from django.shortcuts import render
34
+ from django.views.generic import View
35
+
36
+ from django_admin_react import conf as dar_conf
37
+ from django_admin_react.api.registry import get_admin_site
38
+
39
+ # Theme colours keyed by the resolved colour scheme. Kept here (not in
40
+ # the SPA's CSS-var system) because the manifest is rendered server-side
41
+ # before any CSS loads; these are the install-banner / splash colours
42
+ # Android uses, and they only need to *approximate* the SPA theme.
43
+ _THEME_COLOURS = {
44
+ "light": {"background": "#ffffff", "theme": "#2563eb"},
45
+ "dark": {"background": "#0b0f19", "theme": "#3b82f6"},
46
+ }
47
+
48
+ _DEFAULT_ICONS: list[dict[str, str]] = [
49
+ {"src": "static/dar/icons/icon-192.png", "sizes": "192x192", "type": "image/png"},
50
+ {"src": "static/dar/icons/icon-512.png", "sizes": "512x512", "type": "image/png"},
51
+ {
52
+ "src": "static/dar/icons/icon-512-maskable.png",
53
+ "sizes": "512x512",
54
+ "type": "image/png",
55
+ "purpose": "maskable",
56
+ },
57
+ ]
58
+
59
+
60
+ def _mount(request: HttpRequest, suffix: str) -> str:
61
+ """Reconstruct the consumer's mount prefix from ``request.path``.
62
+
63
+ The view is routed at ``<mount>/<suffix>`` (e.g. ``web.manifest``),
64
+ so stripping the known suffix off ``request.path`` yields the mount.
65
+ Mirrors ``views._mount_from_request`` but is local so this module
66
+ has no import dependency on the SPA index view.
67
+ """
68
+ path = request.path
69
+ idx = path.rfind(suffix)
70
+ if idx == -1:
71
+ return "/"
72
+ return path[:idx] or "/"
73
+
74
+
75
+ def _resolved_scheme(request: HttpRequest) -> str:
76
+ """Resolve light/dark from the ``Sec-CH-Prefers-Color-Scheme`` hint.
77
+
78
+ Pairs with the theming client-hint path (``theming.md`` §2). Any
79
+ value other than a case-insensitive ``"dark"`` resolves to light —
80
+ the safe, neutral default when the hint is absent or unexpected.
81
+ """
82
+ hint = (request.headers.get("Sec-CH-Prefers-Color-Scheme") or "").strip().lower()
83
+ return "dark" if hint == "dark" else "light"
84
+
85
+
86
+ class ManifestView(View):
87
+ """``GET <mount>/web.manifest`` — the PWA web app manifest.
88
+
89
+ Unauthenticated by design (the install prompt needs it pre-login).
90
+ Carries no per-user data; every field is static or mount-/header-
91
+ derived. ``Cache-Control: no-store`` is **not** set — the manifest
92
+ is deliberately cacheable/network-first (``pwa.md`` §2.1).
93
+ """
94
+
95
+ http_method_names = ["get"]
96
+
97
+ def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
98
+ mount = _mount(request, "web.manifest")
99
+ scheme = _resolved_scheme(request)
100
+ colours = _THEME_COLOURS[scheme]
101
+
102
+ admin_site = get_admin_site()
103
+ site_header = getattr(admin_site, "site_header", None)
104
+ name = dar_conf.PWA_NAME or (str(site_header) if site_header else "Django admin")
105
+ short_name = dar_conf.PWA_SHORT_NAME or "Admin"
106
+ icons = dar_conf.PWA_ICONS or _DEFAULT_ICONS
107
+
108
+ manifest = {
109
+ "name": name,
110
+ "short_name": short_name,
111
+ "start_url": mount,
112
+ "scope": mount,
113
+ "display": "standalone",
114
+ "orientation": "any",
115
+ "background_color": colours["background"],
116
+ "theme_color": colours["theme"],
117
+ "icons": icons,
118
+ }
119
+ response = JsonResponse(manifest, content_type="application/manifest+json")
120
+ # Vary on the client hint so a light/dark cache entry doesn't
121
+ # serve the wrong splash colours to the other scheme.
122
+ response["Vary"] = "Sec-CH-Prefers-Color-Scheme"
123
+ return response
124
+
125
+
126
+ class ServiceWorkerView(View):
127
+ """``GET <mount>/sw.js`` — the hand-rolled service worker.
128
+
129
+ Served with ``Service-Worker-Allowed: <mount>`` so the SW can claim
130
+ the whole mount as its scope (a SW's default scope is its own path;
131
+ the header widens it to the mount root). The JS is rendered from a
132
+ template with the mount injected so the SW's fetch interception is
133
+ bounded to the mount and never touches sibling Django views.
134
+ """
135
+
136
+ http_method_names = ["get"]
137
+
138
+ def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
139
+ mount = _mount(request, "sw.js")
140
+ response = render(
141
+ request,
142
+ "admin_react/sw.js",
143
+ {"mount": mount},
144
+ content_type="application/javascript",
145
+ )
146
+ # Allow the SW to control the entire mount, not just ``<mount>/sw.js``.
147
+ response["Service-Worker-Allowed"] = mount
148
+ # The SW script itself should not be cached aggressively — a new
149
+ # deploy must be able to ship a new SW. ``no-cache`` (revalidate)
150
+ # not ``no-store`` so the browser's SW update check still works.
151
+ response["Cache-Control"] = "no-cache"
152
+ return response
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "index.html": {
3
- "file": "assets/index-BE3CZdBI.js",
3
+ "file": "assets/index-Ch1wOBLJ.js",
4
4
  "name": "index",
5
5
  "src": "index.html",
6
6
  "isEntry": true,
7
7
  "css": [
8
- "assets/index-xZLX3uph.css"
8
+ "assets/index-Cf63Q57m.css"
9
9
  ]
10
10
  }
11
11
  }