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.
- {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a3}/PKG-INFO +1 -1
- {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a3}/django_admin_react/api/dates.py +1 -5
- {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a3}/django_admin_react/api/filters.py +5 -1
- {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a3}/django_admin_react/api/inlines.py +3 -9
- {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a3}/django_admin_react/api/serializers.py +1 -2
- {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a3}/django_admin_react/api/urls.py +9 -0
- {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a3}/django_admin_react/api/views/actions.py +15 -1
- {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a3}/django_admin_react/api/views/autocomplete.py +2 -5
- django_admin_react-0.2.0a3/django_admin_react/api/views/create_form.py +96 -0
- {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a3}/django_admin_react/api/views/schema.py +4 -14
- {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a3}/django_admin_react/conf.py +32 -0
- django_admin_react-0.2.0a3/django_admin_react/pwa.py +152 -0
- {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a3}/django_admin_react/static/admin_react/.vite/manifest.json +2 -2
- django_admin_react-0.2.0a3/django_admin_react/static/admin_react/assets/index-BOdTCQF7.js +9 -0
- django_admin_react-0.2.0a3/django_admin_react/static/admin_react/assets/index-BOdTCQF7.js.map +1 -0
- django_admin_react-0.2.0a3/django_admin_react/static/admin_react/assets/index-BgIZIHRa.css +1 -0
- {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a3}/django_admin_react/static/admin_react/index.html +2 -2
- django_admin_react-0.2.0a3/django_admin_react/templates/admin_react/sw.js +136 -0
- {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a3}/django_admin_react/urls.py +8 -0
- {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a3}/django_admin_react/views.py +26 -3
- {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a3}/pyproject.toml +1 -1
- django_admin_react-0.2.0a2/django_admin_react/static/admin_react/assets/index-BE3CZdBI.js +0 -9
- django_admin_react-0.2.0a2/django_admin_react/static/admin_react/assets/index-BE3CZdBI.js.map +0 -1
- django_admin_react-0.2.0a2/django_admin_react/static/admin_react/assets/index-xZLX3uph.css +0 -1
- {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a3}/LICENSE +0 -0
- {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a3}/README.md +0 -0
- {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a3}/django_admin_react/README.md +0 -0
- {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a3}/django_admin_react/__init__.py +0 -0
- {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a3}/django_admin_react/api/README.md +0 -0
- {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a3}/django_admin_react/api/__init__.py +0 -0
- {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a3}/django_admin_react/api/inlines_write.py +0 -0
- {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a3}/django_admin_react/api/panels.py +0 -0
- {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a3}/django_admin_react/api/permissions.py +0 -0
- {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a3}/django_admin_react/api/registry.py +0 -0
- {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a3}/django_admin_react/api/views/README.md +0 -0
- {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a3}/django_admin_react/api/views/__init__.py +0 -0
- {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a3}/django_admin_react/api/views/auth.py +0 -0
- {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a3}/django_admin_react/api/views/bulk.py +0 -0
- {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a3}/django_admin_react/api/views/create.py +0 -0
- {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a3}/django_admin_react/api/views/delete_preview.py +0 -0
- {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a3}/django_admin_react/api/views/destroy.py +0 -0
- {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a3}/django_admin_react/api/views/detail.py +0 -0
- {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a3}/django_admin_react/api/views/history.py +0 -0
- {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a3}/django_admin_react/api/views/list.py +0 -0
- {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a3}/django_admin_react/api/views/registry.py +0 -0
- {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a3}/django_admin_react/api/views/update.py +0 -0
- {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a3}/django_admin_react/api/writes.py +0 -0
- {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a3}/django_admin_react/apps.py +0 -0
- {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a3}/django_admin_react/audit.py +0 -0
- {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a3}/django_admin_react/templates/admin_react/README.md +0 -0
- {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a3}/django_admin_react/templates/admin_react/index.html +0 -0
- {django_admin_react-0.2.0a2 → django_admin_react-0.2.0a3}/django_admin_react/templates/admin_react/login.html +0 -0
|
@@ -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
|
{django_admin_react-0.2.0a2 → django_admin_react-0.2.0a3}/django_admin_react/api/serializers.py
RENAMED
|
@@ -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(),
|
{django_admin_react-0.2.0a2 → django_admin_react-0.2.0a3}/django_admin_react/api/views/actions.py
RENAMED
|
@@ -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
|
-
|
|
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
|
{django_admin_react-0.2.0a2 → django_admin_react-0.2.0a3}/django_admin_react/api/views/schema.py
RENAMED
|
@@ -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
|