django-admin-react 0.1.0a1__py3-none-any.whl
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.
- LICENSE +21 -0
- django_admin_react/README.md +57 -0
- django_admin_react/__init__.py +10 -0
- django_admin_react/api/README.md +31 -0
- django_admin_react/api/__init__.py +5 -0
- django_admin_react/api/permissions.py +80 -0
- django_admin_react/api/registry.py +200 -0
- django_admin_react/api/serializers.py +183 -0
- django_admin_react/api/urls.py +84 -0
- django_admin_react/api/views/README.md +21 -0
- django_admin_react/api/views/__init__.py +6 -0
- django_admin_react/api/views/create.py +141 -0
- django_admin_react/api/views/destroy.py +89 -0
- django_admin_react/api/views/detail.py +283 -0
- django_admin_react/api/views/list.py +271 -0
- django_admin_react/api/views/registry.py +51 -0
- django_admin_react/api/views/update.py +121 -0
- django_admin_react/api/writes.py +325 -0
- django_admin_react/apps.py +27 -0
- django_admin_react/conf.py +77 -0
- django_admin_react/static/admin_react/.vite/manifest.json +11 -0
- django_admin_react/static/admin_react/assets/index-CKxeWYBA.css +1 -0
- django_admin_react/static/admin_react/assets/index-itk7hrnq.js +68 -0
- django_admin_react/static/admin_react/assets/index-itk7hrnq.js.map +1 -0
- django_admin_react/static/admin_react/index.html +13 -0
- django_admin_react/templates/admin_react/README.md +10 -0
- django_admin_react/templates/admin_react/index.html +33 -0
- django_admin_react/urls.py +39 -0
- django_admin_react/views.py +136 -0
- django_admin_react-0.1.0a1.dist-info/LICENSE +21 -0
- django_admin_react-0.1.0a1.dist-info/METADATA +237 -0
- django_admin_react-0.1.0a1.dist-info/RECORD +33 -0
- django_admin_react-0.1.0a1.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
"""Shared helpers for the write endpoints (POST / PATCH / DELETE).
|
|
2
|
+
|
|
3
|
+
Wire contract: ``docs/api-contract.md`` §5 and §6.
|
|
4
|
+
|
|
5
|
+
Hard rules (`SECURITY.md` §3, `ACCEPTANCE.md` §3.1):
|
|
6
|
+
|
|
7
|
+
- Rule 6: Writes go through ``ModelAdmin.get_form()`` — never
|
|
8
|
+
``setattr(obj, ...)`` directly (B-3).
|
|
9
|
+
- Rule 7: Deletes go through ``ModelAdmin.delete_model()`` — never
|
|
10
|
+
``obj.delete()`` (B-4).
|
|
11
|
+
- Rule 12: ``readonly`` / ``exclude`` field names in the payload are a
|
|
12
|
+
400 ``bad_request`` (S-31, B-3).
|
|
13
|
+
- Defense-in-depth: sensitive-name denylist is applied on top of the
|
|
14
|
+
admin's own ``exclude`` so a misconfigured admin still cannot leak.
|
|
15
|
+
|
|
16
|
+
Public surface — every view layer should reach for one of these first:
|
|
17
|
+
|
|
18
|
+
- :func:`bad_request` — uniform 400 envelope.
|
|
19
|
+
- :func:`validation_failed` — uniform 400 with per-field errors.
|
|
20
|
+
- :func:`not_found_response` — canonical 404 envelope.
|
|
21
|
+
- :func:`parse_json_body` — decode a JSON object or return 400.
|
|
22
|
+
- :func:`load_object_or_none` — fetch through ``get_queryset`` or
|
|
23
|
+
return ``None`` (caller emits 404).
|
|
24
|
+
- :func:`writable_field_names` — fields the API will accept on write.
|
|
25
|
+
- :func:`readonly_or_excluded_names`
|
|
26
|
+
— fields the payload may not mention.
|
|
27
|
+
- :func:`reject_forbidden_keys` — payload-shape validation gate.
|
|
28
|
+
- :func:`coerce_fk_values` — accept FK envelope on input.
|
|
29
|
+
- :func:`form_errors_to_envelope`
|
|
30
|
+
— Django form errors → wire shape.
|
|
31
|
+
- :func:`merged_initial_for_update`
|
|
32
|
+
— instance + payload → form data.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import json
|
|
38
|
+
from typing import Any
|
|
39
|
+
|
|
40
|
+
from django.contrib.admin.options import ModelAdmin
|
|
41
|
+
from django.db.models import ForeignKey
|
|
42
|
+
from django.db.models import ManyToManyField
|
|
43
|
+
from django.db.models import Model
|
|
44
|
+
from django.http import HttpRequest
|
|
45
|
+
from django.http import HttpResponse
|
|
46
|
+
from django.http import JsonResponse
|
|
47
|
+
|
|
48
|
+
from django_admin_react.api.serializers import filter_sensitive
|
|
49
|
+
from django_admin_react.api.serializers import is_sensitive_field_name
|
|
50
|
+
|
|
51
|
+
# Canonical 404 body. Deliberately omits the requested app/model/pk —
|
|
52
|
+
# leaking those would give an attacker an oracle for what *would* have
|
|
53
|
+
# existed had they been authorized. See ``SECURITY.md`` §3 rule 12.
|
|
54
|
+
_NOT_FOUND_BODY: dict[str, Any] = {
|
|
55
|
+
"error": {"code": "not_found", "message": "Not found."},
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# --------------------------------------------------------------------------- #
|
|
60
|
+
# Response factories #
|
|
61
|
+
# --------------------------------------------------------------------------- #
|
|
62
|
+
def bad_request(message: str = "Malformed request.") -> HttpResponse:
|
|
63
|
+
"""Return the package's canonical 400 ``bad_request`` envelope.
|
|
64
|
+
|
|
65
|
+
The ``message`` is safe to surface to the client; never include
|
|
66
|
+
request-derived strings here unless they are ``repr()``-quoted
|
|
67
|
+
(``SECURITY.md`` §3 rule 12).
|
|
68
|
+
"""
|
|
69
|
+
body = {"error": {"code": "bad_request", "message": message}}
|
|
70
|
+
response = JsonResponse(body, status=400)
|
|
71
|
+
response["Cache-Control"] = "no-store"
|
|
72
|
+
return response
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def validation_failed(errors: dict[str, list[str]]) -> HttpResponse:
|
|
76
|
+
"""Return a 400 ``validation_failed`` envelope (contract §6).
|
|
77
|
+
|
|
78
|
+
The ``errors`` mapping is already the wire shape — see
|
|
79
|
+
:func:`form_errors_to_envelope` for the canonical converter from
|
|
80
|
+
Django's ``form.errors`` to this shape.
|
|
81
|
+
"""
|
|
82
|
+
body = {
|
|
83
|
+
"error": {
|
|
84
|
+
"code": "validation_failed",
|
|
85
|
+
"message": "One or more fields are invalid.",
|
|
86
|
+
"fields": errors,
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
response = JsonResponse(body, status=400)
|
|
90
|
+
response["Cache-Control"] = "no-store"
|
|
91
|
+
return response
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def not_found_response() -> HttpResponse:
|
|
95
|
+
"""Return the package's canonical 404 envelope (contract §6).
|
|
96
|
+
|
|
97
|
+
Single source of truth for 404 bodies across the view layer; every
|
|
98
|
+
view that needs to emit a 404 imports this rather than rolling its
|
|
99
|
+
own envelope (keeps the leak surface to zero, per
|
|
100
|
+
``SECURITY.md`` §3 rule 12).
|
|
101
|
+
"""
|
|
102
|
+
response = JsonResponse(_NOT_FOUND_BODY, status=404)
|
|
103
|
+
response["Cache-Control"] = "no-store"
|
|
104
|
+
return response
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# --------------------------------------------------------------------------- #
|
|
108
|
+
# Request / object lookup #
|
|
109
|
+
# --------------------------------------------------------------------------- #
|
|
110
|
+
def parse_json_body(request: HttpRequest) -> dict[str, Any] | HttpResponse:
|
|
111
|
+
"""Decode ``request.body`` as a JSON object, or return a 400.
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
- ``{}`` if the body is empty (PATCH with no fields is valid).
|
|
115
|
+
- The parsed dict on success.
|
|
116
|
+
- A ``HttpResponse`` (400) if the body is invalid UTF-8, invalid
|
|
117
|
+
JSON, or not a JSON object (arrays, scalars, etc. are
|
|
118
|
+
rejected — the contract only ever speaks objects).
|
|
119
|
+
|
|
120
|
+
Callers check ``isinstance(result, HttpResponse)`` to branch.
|
|
121
|
+
"""
|
|
122
|
+
raw = request.body or b""
|
|
123
|
+
if not raw:
|
|
124
|
+
return {}
|
|
125
|
+
try:
|
|
126
|
+
decoded = json.loads(raw.decode("utf-8"))
|
|
127
|
+
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
128
|
+
return bad_request("Request body must be valid UTF-8 JSON.")
|
|
129
|
+
if not isinstance(decoded, dict):
|
|
130
|
+
return bad_request("Request body must be a JSON object.")
|
|
131
|
+
return decoded
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def load_object_or_none(
|
|
135
|
+
model: type[Model],
|
|
136
|
+
model_admin: ModelAdmin,
|
|
137
|
+
request: HttpRequest,
|
|
138
|
+
pk: Any,
|
|
139
|
+
) -> Model | None:
|
|
140
|
+
"""Fetch one row through the admin's queryset, or ``None`` on miss.
|
|
141
|
+
|
|
142
|
+
Three lookup failures collapse to ``None`` (callers convert to
|
|
143
|
+
404, never to 500):
|
|
144
|
+
|
|
145
|
+
- ``DoesNotExist`` — pk valid but no matching row, or the row was
|
|
146
|
+
filtered out by ``ModelAdmin.get_queryset(request)``.
|
|
147
|
+
- ``ValueError`` — pk could not be parsed for the field's type
|
|
148
|
+
(e.g., ``"abc"`` against an ``IntegerField``).
|
|
149
|
+
- ``TypeError`` — similar parse failure for a custom pk type.
|
|
150
|
+
|
|
151
|
+
Centralizing this here means every "load or 404" call site has
|
|
152
|
+
the same exception surface and the same security posture
|
|
153
|
+
(queryset never bypassed, parse errors never leak).
|
|
154
|
+
"""
|
|
155
|
+
try:
|
|
156
|
+
return model_admin.get_queryset(request).get(pk=pk)
|
|
157
|
+
except (model.DoesNotExist, ValueError, TypeError):
|
|
158
|
+
return None
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# --------------------------------------------------------------------------- #
|
|
162
|
+
# Field-set computation #
|
|
163
|
+
# --------------------------------------------------------------------------- #
|
|
164
|
+
def writable_field_names(
|
|
165
|
+
model: type[Model],
|
|
166
|
+
model_admin: ModelAdmin,
|
|
167
|
+
request: HttpRequest,
|
|
168
|
+
obj: Model | None,
|
|
169
|
+
) -> list[str]:
|
|
170
|
+
"""Field names the API will accept in a create or update payload.
|
|
171
|
+
|
|
172
|
+
Computed as ``get_fields`` minus ``get_exclude`` minus
|
|
173
|
+
``get_readonly_fields`` minus the sensitive-name denylist
|
|
174
|
+
(``ACCEPTANCE.md`` §4.7 S-31) minus ``ManyToManyField``
|
|
175
|
+
(unsupported in v1 per ``docs/api-contract.md`` §4).
|
|
176
|
+
|
|
177
|
+
Defense-in-depth: even if a ``ModelAdmin`` author forgets to
|
|
178
|
+
``exclude`` a sensitive-named field, the substring match keeps
|
|
179
|
+
it out of the writable set.
|
|
180
|
+
"""
|
|
181
|
+
declared = list(model_admin.get_fields(request, obj) or ())
|
|
182
|
+
excluded = set(model_admin.get_exclude(request, obj) or ())
|
|
183
|
+
readonly = set(model_admin.get_readonly_fields(request, obj) or ())
|
|
184
|
+
out: list[str] = []
|
|
185
|
+
for name in declared:
|
|
186
|
+
if name in excluded or name in readonly or is_sensitive_field_name(name):
|
|
187
|
+
continue
|
|
188
|
+
if isinstance(_safe_get_field(model, name), ManyToManyField):
|
|
189
|
+
continue
|
|
190
|
+
out.append(name)
|
|
191
|
+
return filter_sensitive(out)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def readonly_or_excluded_names(
|
|
195
|
+
model_admin: ModelAdmin,
|
|
196
|
+
request: HttpRequest,
|
|
197
|
+
obj: Model | None,
|
|
198
|
+
) -> set[str]:
|
|
199
|
+
"""Field names a payload may not even mention.
|
|
200
|
+
|
|
201
|
+
Used by :func:`reject_forbidden_keys` to emit the precise message
|
|
202
|
+
``"Field 'x' is read-only."`` instead of the generic
|
|
203
|
+
``"Unknown field 'x'."`` when the admin marks a field as
|
|
204
|
+
readonly or excluded. Both responses are 400 per contract §5;
|
|
205
|
+
the distinction is a UX courtesy for the SPA.
|
|
206
|
+
"""
|
|
207
|
+
excluded = set(model_admin.get_exclude(request, obj) or ())
|
|
208
|
+
readonly = set(model_admin.get_readonly_fields(request, obj) or ())
|
|
209
|
+
return excluded | readonly
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
# --------------------------------------------------------------------------- #
|
|
213
|
+
# Payload validation #
|
|
214
|
+
# --------------------------------------------------------------------------- #
|
|
215
|
+
def reject_forbidden_keys(
|
|
216
|
+
payload: dict[str, Any],
|
|
217
|
+
writable: list[str],
|
|
218
|
+
forbidden: set[str],
|
|
219
|
+
) -> HttpResponse | None:
|
|
220
|
+
"""Validate the shape of a write payload — return a 400 or ``None``.
|
|
221
|
+
|
|
222
|
+
Three rejection reasons, each yielding ``bad_request`` per
|
|
223
|
+
``docs/api-contract.md`` §6:
|
|
224
|
+
|
|
225
|
+
1. The key is read-only or excluded by the admin (§5.2).
|
|
226
|
+
2. The key matches the sensitive-name denylist
|
|
227
|
+
(defense-in-depth, even if the admin's ``writable`` list let
|
|
228
|
+
it through somehow).
|
|
229
|
+
3. The key is not in ``writable`` at all (§5.1, unknown field).
|
|
230
|
+
|
|
231
|
+
Rejecting *before* form construction means a hostile payload
|
|
232
|
+
cannot trigger ``ModelForm`` side effects (e.g. FK queries) on
|
|
233
|
+
field names the admin never declared.
|
|
234
|
+
"""
|
|
235
|
+
writable_set = set(writable)
|
|
236
|
+
for key in payload:
|
|
237
|
+
if key in forbidden:
|
|
238
|
+
return bad_request(f"Field {key!r} is read-only.")
|
|
239
|
+
if is_sensitive_field_name(key):
|
|
240
|
+
return bad_request(f"Field {key!r} is not writable.")
|
|
241
|
+
if key not in writable_set:
|
|
242
|
+
return bad_request(f"Unknown field {key!r}.")
|
|
243
|
+
return None
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def coerce_fk_values(
|
|
247
|
+
payload: dict[str, Any],
|
|
248
|
+
model: type[Model],
|
|
249
|
+
) -> dict[str, Any]:
|
|
250
|
+
"""Normalize ForeignKey values to the bare pk the form layer expects.
|
|
251
|
+
|
|
252
|
+
The wire contract sends FKs *out* as ``{"id": pk, "label": str}``
|
|
253
|
+
(§4) but accepts bare pk *in* (§5.1). Clients that echo the read
|
|
254
|
+
shape back would otherwise hit a form-validation error even when
|
|
255
|
+
their intent is clear. Recognizing the envelope on input keeps
|
|
256
|
+
the SPA's edit-in-place flow honest without weakening
|
|
257
|
+
validation — Django will still reject any pk that does not
|
|
258
|
+
resolve to a real related row.
|
|
259
|
+
"""
|
|
260
|
+
out: dict[str, Any] = {}
|
|
261
|
+
for key, value in payload.items():
|
|
262
|
+
model_field = _safe_get_field(model, key)
|
|
263
|
+
is_fk_envelope = (
|
|
264
|
+
isinstance(model_field, ForeignKey) and isinstance(value, dict) and "id" in value
|
|
265
|
+
)
|
|
266
|
+
out[key] = value["id"] if is_fk_envelope else value
|
|
267
|
+
return out
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def form_errors_to_envelope(form: Any) -> dict[str, list[str]]:
|
|
271
|
+
"""Convert a Django form's ``errors`` mapping to the wire shape.
|
|
272
|
+
|
|
273
|
+
Non-field errors are surfaced under the empty-string key
|
|
274
|
+
(normalized from Django's ``__all__`` convention so clients
|
|
275
|
+
don't have to know that magic name).
|
|
276
|
+
"""
|
|
277
|
+
errors: dict[str, list[str]] = {}
|
|
278
|
+
for field_name, error_list in form.errors.items():
|
|
279
|
+
key = "" if field_name == "__all__" else field_name
|
|
280
|
+
errors[key] = [str(e) for e in error_list]
|
|
281
|
+
return errors
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def merged_initial_for_update(
|
|
285
|
+
obj: Model,
|
|
286
|
+
writable: list[str],
|
|
287
|
+
payload: dict[str, Any],
|
|
288
|
+
model: type[Model],
|
|
289
|
+
) -> dict[str, Any]:
|
|
290
|
+
"""Build the ``data`` dict for a PATCH form: instance values + payload.
|
|
291
|
+
|
|
292
|
+
Django ``ModelForm`` validates the whole form on every save, even
|
|
293
|
+
on partial updates. We therefore seed every writable field with
|
|
294
|
+
the instance's current value, then overlay the user-supplied
|
|
295
|
+
payload. This mirrors what Django admin's change-view does.
|
|
296
|
+
|
|
297
|
+
FK fields are seeded with ``<name>_id`` because that is the wire
|
|
298
|
+
shape the form's choice field expects — passing the related
|
|
299
|
+
instance directly would trigger an extra DB query on a hot path.
|
|
300
|
+
"""
|
|
301
|
+
merged: dict[str, Any] = {}
|
|
302
|
+
for name in writable:
|
|
303
|
+
if isinstance(_safe_get_field(model, name), ForeignKey):
|
|
304
|
+
merged[name] = getattr(obj, f"{name}_id", None)
|
|
305
|
+
else:
|
|
306
|
+
merged[name] = getattr(obj, name, None)
|
|
307
|
+
merged.update(coerce_fk_values(payload, model))
|
|
308
|
+
return merged
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
# --------------------------------------------------------------------------- #
|
|
312
|
+
# Internals #
|
|
313
|
+
# --------------------------------------------------------------------------- #
|
|
314
|
+
def _safe_get_field(model: type[Model], name: str):
|
|
315
|
+
"""Return ``model._meta.get_field(name)`` or ``None`` if not found.
|
|
316
|
+
|
|
317
|
+
Centralized so callers don't need to know that ``get_field``
|
|
318
|
+
raises ``FieldDoesNotExist`` (or any subclass) — the contract
|
|
319
|
+
here is "field or None", which is what every call site in this
|
|
320
|
+
module actually wants.
|
|
321
|
+
"""
|
|
322
|
+
try:
|
|
323
|
+
return model._meta.get_field(name)
|
|
324
|
+
except Exception:
|
|
325
|
+
return None
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Django AppConfig for django_admin_react.
|
|
2
|
+
|
|
3
|
+
Registering this AppConfig in the consumer's `INSTALLED_APPS` is the
|
|
4
|
+
only side effect of adding the package. The real wiring (URLs, API,
|
|
5
|
+
templates, static assets) is opt-in via the consumer's own `urls.py`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from django.apps import AppConfig
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class DjangoAdminReactConfig(AppConfig):
|
|
12
|
+
"""Django app config — the only side effect of adding the package.
|
|
13
|
+
|
|
14
|
+
The four attributes are the standard Django ``AppConfig`` contract:
|
|
15
|
+
|
|
16
|
+
- ``name`` — Python import path; required by Django's app registry.
|
|
17
|
+
- ``label`` — short identifier used in migrations and admin URLs.
|
|
18
|
+
- ``verbose_name`` — human-readable name shown in the admin index.
|
|
19
|
+
- ``default_auto_field`` — bigint primary keys for any future models
|
|
20
|
+
the package adds (none today, but pinning the default avoids a
|
|
21
|
+
Django warning and locks the choice in for forwards compat).
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
name = "django_admin_react"
|
|
25
|
+
label = "django_admin_react"
|
|
26
|
+
verbose_name = "Django Admin React"
|
|
27
|
+
default_auto_field = "django.db.models.BigAutoField"
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Lazy settings wrapper for django_admin_react.
|
|
2
|
+
|
|
3
|
+
All package settings live under a single optional dict
|
|
4
|
+
``settings.DJANGO_ADMIN_REACT``. Defaults are applied lazily so that
|
|
5
|
+
adding the app to ``INSTALLED_APPS`` does not require a settings entry.
|
|
6
|
+
|
|
7
|
+
Usage in package code:
|
|
8
|
+
|
|
9
|
+
from django_admin_react.conf import settings
|
|
10
|
+
settings.MAX_PAGE_SIZE
|
|
11
|
+
|
|
12
|
+
Nothing in the package should read ``django.conf.settings.DJANGO_ADMIN_REACT``
|
|
13
|
+
directly — go through this module so defaults are consistent.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from django.conf import settings as django_settings
|
|
22
|
+
|
|
23
|
+
DEFAULTS: dict[str, Any] = {
|
|
24
|
+
"ADMIN_SITE": "django.contrib.admin.site",
|
|
25
|
+
"DEFAULT_PAGE_SIZE": 25,
|
|
26
|
+
"MAX_PAGE_SIZE": 200,
|
|
27
|
+
"ENABLE_PROFILING": False,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class _PackageSettings:
|
|
33
|
+
"""Resolved package settings.
|
|
34
|
+
|
|
35
|
+
Real implementation lands in PR #2. For now this is a stub so other
|
|
36
|
+
modules can import the typed attribute names.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
ADMIN_SITE: str = DEFAULTS["ADMIN_SITE"]
|
|
40
|
+
DEFAULT_PAGE_SIZE: int = DEFAULTS["DEFAULT_PAGE_SIZE"]
|
|
41
|
+
MAX_PAGE_SIZE: int = DEFAULTS["MAX_PAGE_SIZE"]
|
|
42
|
+
ENABLE_PROFILING: bool = DEFAULTS["ENABLE_PROFILING"]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _load() -> _PackageSettings:
|
|
46
|
+
"""Merge the consumer's overrides with ``DEFAULTS``.
|
|
47
|
+
|
|
48
|
+
Unknown keys raise ``ValueError`` so a typo in
|
|
49
|
+
``settings.DJANGO_ADMIN_REACT`` is caught at startup rather than
|
|
50
|
+
silently ignored. Returns an immutable ``_PackageSettings``.
|
|
51
|
+
"""
|
|
52
|
+
user_overrides = getattr(django_settings, "DJANGO_ADMIN_REACT", {}) or {}
|
|
53
|
+
merged = {**DEFAULTS, **user_overrides}
|
|
54
|
+
# Reject unknown keys defensively to surface typos early.
|
|
55
|
+
unknown = set(merged) - set(DEFAULTS)
|
|
56
|
+
if unknown:
|
|
57
|
+
raise ValueError("Unknown DJANGO_ADMIN_REACT keys: " + ", ".join(sorted(unknown)))
|
|
58
|
+
return _PackageSettings(**merged)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# Lazily resolve on first access; cache.
|
|
62
|
+
_cached: _PackageSettings | None = None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def __getattr__(name: str) -> Any: # pragma: no cover — thin shim
|
|
66
|
+
"""Module-level ``__getattr__`` (PEP 562) so callers can write
|
|
67
|
+
``from django_admin_react.conf import settings`` or
|
|
68
|
+
``conf.MAX_PAGE_SIZE`` without a separate accessor.
|
|
69
|
+
|
|
70
|
+
First access triggers ``_load()`` and caches the result; later
|
|
71
|
+
accesses return cached attributes. Reload requires re-importing
|
|
72
|
+
the module (matches Django's own settings semantics).
|
|
73
|
+
"""
|
|
74
|
+
global _cached
|
|
75
|
+
if _cached is None:
|
|
76
|
+
_cached = _load()
|
|
77
|
+
return getattr(_cached, name)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
*,:before,:after{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }*,:before,:after{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}:before,:after{--tw-content: ""}html,:host{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji",Segoe UI Symbol,"Noto Color Emoji";font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.visible{visibility:visible}.col-span-2{grid-column:span 2 / span 2}.mb-1{margin-bottom:.25rem}.mb-6{margin-bottom:1.5rem}.ml-2{margin-left:.5rem}.mt-1{margin-top:.25rem}.mt-2{margin-top:.5rem}.mt-4{margin-top:1rem}.block{display:block}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.h-10{height:2.5rem}.h-4{height:1rem}.h-6{height:1.5rem}.h-full{height:100%}.min-h-screen{min-height:100vh}.w-10{width:2.5rem}.w-4{width:1rem}.w-6{width:1.5rem}.w-64{width:16rem}.w-full{width:100%}.min-w-full{min-width:100%}.max-w-prose{max-width:65ch}.flex-1{flex:1 1 0%}.shrink-0{flex-shrink:0}@keyframes spin{to{transform:rotate(360deg)}}.animate-spin{animation:spin 1s linear infinite}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.flex-col{flex-direction:column}.items-end{align-items:flex-end}.items-center{align-items:center}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-2{gap:.5rem}.gap-4{gap:1rem}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.25rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem * var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem * var(--tw-space-y-reverse))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem * var(--tw-space-y-reverse))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse: 0;border-top-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px * var(--tw-divide-y-reverse))}.divide-gray-100>:not([hidden])~:not([hidden]){--tw-divide-opacity: 1;border-color:rgb(243 244 246 / var(--tw-divide-opacity, 1))}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.whitespace-pre-wrap{white-space:pre-wrap}.rounded{border-radius:.25rem}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.border{border-width:1px}.border-b{border-bottom-width:1px}.border-blue-600{--tw-border-opacity: 1;border-color:rgb(37 99 235 / var(--tw-border-opacity, 1))}.border-gray-200{--tw-border-opacity: 1;border-color:rgb(229 231 235 / var(--tw-border-opacity, 1))}.border-gray-300{--tw-border-opacity: 1;border-color:rgb(209 213 219 / var(--tw-border-opacity, 1))}.border-red-500{--tw-border-opacity: 1;border-color:rgb(239 68 68 / var(--tw-border-opacity, 1))}.border-red-600{--tw-border-opacity: 1;border-color:rgb(220 38 38 / var(--tw-border-opacity, 1))}.border-transparent{border-color:transparent}.bg-amber-50{--tw-bg-opacity: 1;background-color:rgb(255 251 235 / var(--tw-bg-opacity, 1))}.bg-blue-50{--tw-bg-opacity: 1;background-color:rgb(239 246 255 / var(--tw-bg-opacity, 1))}.bg-blue-600{--tw-bg-opacity: 1;background-color:rgb(37 99 235 / var(--tw-bg-opacity, 1))}.bg-gray-50{--tw-bg-opacity: 1;background-color:rgb(249 250 251 / var(--tw-bg-opacity, 1))}.bg-gray-900{--tw-bg-opacity: 1;background-color:rgb(17 24 39 / var(--tw-bg-opacity, 1))}.bg-green-50{--tw-bg-opacity: 1;background-color:rgb(240 253 244 / var(--tw-bg-opacity, 1))}.bg-red-50{--tw-bg-opacity: 1;background-color:rgb(254 242 242 / var(--tw-bg-opacity, 1))}.bg-red-600{--tw-bg-opacity: 1;background-color:rgb(220 38 38 / var(--tw-bg-opacity, 1))}.bg-transparent{background-color:transparent}.bg-white{--tw-bg-opacity: 1;background-color:rgb(255 255 255 / var(--tw-bg-opacity, 1))}.p-4{padding:1rem}.p-6{padding:1.5rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-12{padding-top:3rem;padding-bottom:3rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-8{padding-top:2rem;padding-bottom:2rem}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.text-2xl{font-size:1.5rem;line-height:2rem}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xs{font-size:.75rem;line-height:1rem}.font-medium{font-weight:500}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.capitalize{text-transform:capitalize}.tracking-wide{letter-spacing:.025em}.text-amber-700{--tw-text-opacity: 1;color:rgb(180 83 9 / var(--tw-text-opacity, 1))}.text-blue-600{--tw-text-opacity: 1;color:rgb(37 99 235 / var(--tw-text-opacity, 1))}.text-blue-700{--tw-text-opacity: 1;color:rgb(29 78 216 / var(--tw-text-opacity, 1))}.text-gray-100{--tw-text-opacity: 1;color:rgb(243 244 246 / var(--tw-text-opacity, 1))}.text-gray-300{--tw-text-opacity: 1;color:rgb(209 213 219 / var(--tw-text-opacity, 1))}.text-gray-400{--tw-text-opacity: 1;color:rgb(156 163 175 / var(--tw-text-opacity, 1))}.text-gray-500{--tw-text-opacity: 1;color:rgb(107 114 128 / var(--tw-text-opacity, 1))}.text-gray-600{--tw-text-opacity: 1;color:rgb(75 85 99 / var(--tw-text-opacity, 1))}.text-gray-700{--tw-text-opacity: 1;color:rgb(55 65 81 / var(--tw-text-opacity, 1))}.text-gray-900{--tw-text-opacity: 1;color:rgb(17 24 39 / var(--tw-text-opacity, 1))}.text-green-700{--tw-text-opacity: 1;color:rgb(21 128 61 / var(--tw-text-opacity, 1))}.text-red-600{--tw-text-opacity: 1;color:rgb(220 38 38 / var(--tw-text-opacity, 1))}.text-red-700{--tw-text-opacity: 1;color:rgb(185 28 28 / var(--tw-text-opacity, 1))}.text-white{--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity, 1))}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.opacity-25{opacity:.25}.opacity-75{opacity:.75}.shadow-sm{--tw-shadow: 0 1px 2px 0 rgb(0 0 0 / .05);--tw-shadow-colored: 0 1px 2px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}html,body,#root{height:100%}.hover\:bg-blue-700:hover{--tw-bg-opacity: 1;background-color:rgb(29 78 216 / var(--tw-bg-opacity, 1))}.hover\:bg-gray-100:hover{--tw-bg-opacity: 1;background-color:rgb(243 244 246 / var(--tw-bg-opacity, 1))}.hover\:bg-gray-50:hover{--tw-bg-opacity: 1;background-color:rgb(249 250 251 / var(--tw-bg-opacity, 1))}.hover\:bg-gray-800:hover{--tw-bg-opacity: 1;background-color:rgb(31 41 55 / var(--tw-bg-opacity, 1))}.hover\:bg-red-700:hover{--tw-bg-opacity: 1;background-color:rgb(185 28 28 / var(--tw-bg-opacity, 1))}.hover\:text-white:hover{--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity, 1))}.hover\:underline:hover{text-decoration-line:underline}.hover\:no-underline:hover{text-decoration-line:none}.focus\:border-blue-500:focus{--tw-border-opacity: 1;border-color:rgb(59 130 246 / var(--tw-border-opacity, 1))}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.focus\:ring-2:focus{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.focus\:ring-blue-500:focus{--tw-ring-opacity: 1;--tw-ring-color: rgb(59 130 246 / var(--tw-ring-opacity, 1))}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-50:disabled{opacity:.5}@media (min-width: 640px){.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width: 1024px){.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}
|