django-admin-react 0.2.0a2__tar.gz → 0.2.0a3__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.0a3}/PKG-INFO +1 -1
  2. {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a3}/django_admin_react/api/dates.py +1 -5
  3. {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a3}/django_admin_react/api/filters.py +5 -1
  4. {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a3}/django_admin_react/api/inlines.py +3 -9
  5. {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a3}/django_admin_react/api/serializers.py +1 -2
  6. {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a3}/django_admin_react/api/urls.py +9 -0
  7. {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a3}/django_admin_react/api/views/actions.py +15 -1
  8. {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a3}/django_admin_react/api/views/autocomplete.py +2 -5
  9. django_admin_react-0.2.0a3/django_admin_react/api/views/create_form.py +96 -0
  10. {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a3}/django_admin_react/api/views/schema.py +4 -14
  11. {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a3}/django_admin_react/conf.py +32 -0
  12. django_admin_react-0.2.0a3/django_admin_react/pwa.py +152 -0
  13. {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a3}/django_admin_react/static/admin_react/.vite/manifest.json +2 -2
  14. django_admin_react-0.2.0a3/django_admin_react/static/admin_react/assets/index-BOdTCQF7.js +9 -0
  15. django_admin_react-0.2.0a3/django_admin_react/static/admin_react/assets/index-BOdTCQF7.js.map +1 -0
  16. django_admin_react-0.2.0a3/django_admin_react/static/admin_react/assets/index-BgIZIHRa.css +1 -0
  17. {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a3}/django_admin_react/static/admin_react/index.html +2 -2
  18. django_admin_react-0.2.0a3/django_admin_react/templates/admin_react/sw.js +136 -0
  19. {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a3}/django_admin_react/urls.py +8 -0
  20. {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a3}/django_admin_react/views.py +26 -3
  21. {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a3}/pyproject.toml +1 -1
  22. django_admin_react-0.2.0a2/django_admin_react/static/admin_react/assets/index-BE3CZdBI.js +0 -9
  23. django_admin_react-0.2.0a2/django_admin_react/static/admin_react/assets/index-BE3CZdBI.js.map +0 -1
  24. django_admin_react-0.2.0a2/django_admin_react/static/admin_react/assets/index-xZLX3uph.css +0 -1
  25. {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a3}/LICENSE +0 -0
  26. {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a3}/README.md +0 -0
  27. {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a3}/django_admin_react/README.md +0 -0
  28. {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a3}/django_admin_react/__init__.py +0 -0
  29. {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a3}/django_admin_react/api/README.md +0 -0
  30. {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a3}/django_admin_react/api/__init__.py +0 -0
  31. {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a3}/django_admin_react/api/inlines_write.py +0 -0
  32. {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a3}/django_admin_react/api/panels.py +0 -0
  33. {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a3}/django_admin_react/api/permissions.py +0 -0
  34. {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a3}/django_admin_react/api/registry.py +0 -0
  35. {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a3}/django_admin_react/api/views/README.md +0 -0
  36. {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a3}/django_admin_react/api/views/__init__.py +0 -0
  37. {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a3}/django_admin_react/api/views/auth.py +0 -0
  38. {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a3}/django_admin_react/api/views/bulk.py +0 -0
  39. {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a3}/django_admin_react/api/views/create.py +0 -0
  40. {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a3}/django_admin_react/api/views/delete_preview.py +0 -0
  41. {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a3}/django_admin_react/api/views/destroy.py +0 -0
  42. {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a3}/django_admin_react/api/views/detail.py +0 -0
  43. {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a3}/django_admin_react/api/views/history.py +0 -0
  44. {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a3}/django_admin_react/api/views/list.py +0 -0
  45. {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a3}/django_admin_react/api/views/registry.py +0 -0
  46. {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a3}/django_admin_react/api/views/update.py +0 -0
  47. {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a3}/django_admin_react/api/writes.py +0 -0
  48. {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a3}/django_admin_react/apps.py +0 -0
  49. {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a3}/django_admin_react/audit.py +0 -0
  50. {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a3}/django_admin_react/templates/admin_react/README.md +0 -0
  51. {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a3}/django_admin_react/templates/admin_react/index.html +0 -0
  52. {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a3}/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.0a3
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)
@@ -124,9 +124,7 @@ def _spec_for_inline(
124
124
 
125
125
  rows: list[dict[str, Any]] = []
126
126
  if can_view:
127
- rows = _rows_for_inline(
128
- inline, parent, fk_name, visible_fields, request
129
- )
127
+ rows = _rows_for_inline(inline, parent, fk_name, visible_fields, request)
130
128
 
131
129
  return {
132
130
  "name": fk_name + "_set" if not hasattr(child_model, fk_name + "_set") else fk_name,
@@ -183,9 +181,7 @@ def _visible_inline_fields(
183
181
  visible = [
184
182
  name
185
183
  for name in declared
186
- if name not in excluded
187
- and name != fk_back
188
- and not is_sensitive_field_name(name)
184
+ if name not in excluded and name != fk_back and not is_sensitive_field_name(name)
189
185
  ]
190
186
  return filter_sensitive(visible)
191
187
 
@@ -246,7 +242,5 @@ def _rows_for_inline(
246
242
  fields_payload[name] = [serialize_fk_value(r) for r in related]
247
243
  else:
248
244
  fields_payload[name] = serialize_value(value, field=model_field)
249
- rows.append(
250
- {"pk": obj.pk, "label": label_for(obj), "fields": fields_payload}
251
- )
245
+ rows.append({"pk": obj.pk, "label": label_for(obj), "fields": fields_payload})
252
246
  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
 
@@ -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
@@ -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-BOdTCQF7.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-BgIZIHRa.css"
9
9
  ]
10
10
  }
11
11
  }