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