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.
Files changed (33) hide show
  1. LICENSE +21 -0
  2. django_admin_react/README.md +57 -0
  3. django_admin_react/__init__.py +10 -0
  4. django_admin_react/api/README.md +31 -0
  5. django_admin_react/api/__init__.py +5 -0
  6. django_admin_react/api/permissions.py +80 -0
  7. django_admin_react/api/registry.py +200 -0
  8. django_admin_react/api/serializers.py +183 -0
  9. django_admin_react/api/urls.py +84 -0
  10. django_admin_react/api/views/README.md +21 -0
  11. django_admin_react/api/views/__init__.py +6 -0
  12. django_admin_react/api/views/create.py +141 -0
  13. django_admin_react/api/views/destroy.py +89 -0
  14. django_admin_react/api/views/detail.py +283 -0
  15. django_admin_react/api/views/list.py +271 -0
  16. django_admin_react/api/views/registry.py +51 -0
  17. django_admin_react/api/views/update.py +121 -0
  18. django_admin_react/api/writes.py +325 -0
  19. django_admin_react/apps.py +27 -0
  20. django_admin_react/conf.py +77 -0
  21. django_admin_react/static/admin_react/.vite/manifest.json +11 -0
  22. django_admin_react/static/admin_react/assets/index-CKxeWYBA.css +1 -0
  23. django_admin_react/static/admin_react/assets/index-itk7hrnq.js +68 -0
  24. django_admin_react/static/admin_react/assets/index-itk7hrnq.js.map +1 -0
  25. django_admin_react/static/admin_react/index.html +13 -0
  26. django_admin_react/templates/admin_react/README.md +10 -0
  27. django_admin_react/templates/admin_react/index.html +33 -0
  28. django_admin_react/urls.py +39 -0
  29. django_admin_react/views.py +136 -0
  30. django_admin_react-0.1.0a1.dist-info/LICENSE +21 -0
  31. django_admin_react-0.1.0a1.dist-info/METADATA +237 -0
  32. django_admin_react-0.1.0a1.dist-info/RECORD +33 -0
  33. django_admin_react-0.1.0a1.dist-info/WHEEL +4 -0
@@ -0,0 +1,21 @@
1
+ # django_admin_react/api/views/
2
+
3
+ One module per endpoint, mirroring [`/docs/api-contract.md`](../../../docs/api-contract.md).
4
+
5
+ | Module | Endpoint | Lands in PR |
6
+ | -------------- | ------------------------------------------------- | ----------- |
7
+ | `registry.py` | `GET /api/v1/registry/` | #3 |
8
+ | `list.py` | `GET /api/v1/<app>/<model>/` | #4 |
9
+ | `detail.py` | `GET /api/v1/<app>/<model>/<pk>/` | #4 |
10
+ | `create.py` | `POST /api/v1/<app>/<model>/` | #5 |
11
+ | `update.py` | `PATCH /api/v1/<app>/<model>/<pk>/` | #5 |
12
+ | `delete.py` | `DELETE /api/v1/<app>/<model>/<pk>/` | #5 |
13
+
14
+ Each view must:
15
+
16
+ 1. Authenticate via the package's default permission helper
17
+ (staff + `AdminSite.has_permission`).
18
+ 2. Resolve the target `ModelAdmin` via `admin.site._registry`.
19
+ 3. Delegate to the appropriate `ModelAdmin.*` method (see
20
+ `ARCHITECTURE.md` §4.1).
21
+ 4. Serialize through `api/serializers.py` only.
@@ -0,0 +1,6 @@
1
+ """API view modules.
2
+
3
+ One file per endpoint group. See ``docs/api-contract.md`` for the wire
4
+ contracts and ``ARCHITECTURE.md`` §4.1 for the ModelAdmin methods each
5
+ view must delegate to.
6
+ """
@@ -0,0 +1,141 @@
1
+ """``POST /api/v1/<app>/<model>/`` — create endpoint.
2
+
3
+ Wire contract: ``docs/api-contract.md`` §5.1.
4
+
5
+ Hard rules (`SECURITY.md` §3, `ACCEPTANCE.md` §3.1):
6
+
7
+ - Rule 1: Staff + ``AdminSite.has_permission`` gate.
8
+ - Rule 3: Model resolved through ``admin.site._registry`` (B-7).
9
+ - Rule 6: Writes go through ``ModelAdmin.get_form()`` then
10
+ ``save_model(..., change=False)`` (B-3).
11
+ - Rule 12: Unknown / readonly / excluded / sensitive payload keys → 400,
12
+ never a silent drop.
13
+ - CSRF: No ``@csrf_exempt`` — Django's middleware enforces.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from typing import Any
19
+
20
+ from django.db import transaction
21
+ from django.http import HttpRequest
22
+ from django.http import HttpResponse
23
+ from django.http import JsonResponse
24
+ from django.views.generic import View
25
+
26
+ from django_admin_react.api.permissions import forbidden_response
27
+ from django_admin_react.api.permissions import is_admin_user
28
+ from django_admin_react.api.registry import get_admin_site
29
+ from django_admin_react.api.registry import resolve_model
30
+ from django_admin_react.api.serializers import label_for
31
+ from django_admin_react.api.writes import coerce_fk_values
32
+ from django_admin_react.api.writes import form_errors_to_envelope
33
+ from django_admin_react.api.writes import not_found_response
34
+ from django_admin_react.api.writes import parse_json_body
35
+ from django_admin_react.api.writes import readonly_or_excluded_names
36
+ from django_admin_react.api.writes import reject_forbidden_keys
37
+ from django_admin_react.api.writes import validation_failed
38
+ from django_admin_react.api.writes import writable_field_names
39
+
40
+
41
+ class CreateView(View):
42
+ """``POST /api/v1/<app_label>/<model_name>/``."""
43
+
44
+ http_method_names = ["post"]
45
+
46
+ def post(
47
+ self,
48
+ request: HttpRequest,
49
+ app_label: str,
50
+ model_name: str,
51
+ *args: Any,
52
+ **kwargs: Any,
53
+ ) -> HttpResponse:
54
+ """Create a new instance (contract §5.1).
55
+
56
+ Gates: ``is_admin_user`` → ``resolve_model`` →
57
+ ``has_add_permission(request)``. CSRF enforcement is Django's
58
+ ``CsrfViewMiddleware`` — no ``@csrf_exempt`` (rule 4 /
59
+ ACCEPTANCE §4.6 S-26).
60
+
61
+ Payload validation runs **before** the form is built:
62
+
63
+ - Unknown keys → 400 ``bad_request``.
64
+ - Keys matching ``get_readonly_fields`` or ``get_exclude`` →
65
+ 400 (rule 12 / S-22, S-23).
66
+ - Keys matching the sensitive-name denylist → 400 (S-31).
67
+
68
+ The actual write goes through ``ModelAdmin.get_form()`` →
69
+ ``form.save(commit=False)`` → ``model_admin.save_model(...)``
70
+ — never ``setattr`` (rule 6 / B-3). Wrapped in
71
+ ``transaction.atomic()``.
72
+ """
73
+ admin_site = get_admin_site()
74
+ if not is_admin_user(request, admin_site=admin_site):
75
+ return forbidden_response()
76
+
77
+ resolved = resolve_model(admin_site, request, app_label, model_name)
78
+ if resolved is None:
79
+ return not_found_response()
80
+ model, model_admin = resolved
81
+
82
+ if not model_admin.has_add_permission(request):
83
+ return forbidden_response()
84
+
85
+ parsed = parse_json_body(request)
86
+ if isinstance(parsed, HttpResponse):
87
+ return parsed
88
+ payload: dict[str, Any] = parsed
89
+
90
+ writable = writable_field_names(model, model_admin, request, obj=None)
91
+ forbidden = readonly_or_excluded_names(model_admin, request, obj=None)
92
+ rejection = reject_forbidden_keys(payload, writable, forbidden)
93
+ if rejection is not None:
94
+ return rejection
95
+
96
+ form = model_admin.get_form(request, obj=None)(
97
+ data=coerce_fk_values(payload, model),
98
+ files=None,
99
+ )
100
+ if not form.is_valid():
101
+ return validation_failed(form_errors_to_envelope(form))
102
+
103
+ with transaction.atomic():
104
+ instance = form.save(commit=False)
105
+ model_admin.save_model(request, instance, form, change=False)
106
+ form.save_m2m()
107
+
108
+ body = {
109
+ "pk": instance.pk,
110
+ "label": label_for(instance),
111
+ "redirect": _redirect_for(
112
+ request,
113
+ model._meta.app_label,
114
+ model._meta.model_name,
115
+ instance.pk,
116
+ ),
117
+ }
118
+ response = JsonResponse(body, status=201)
119
+ response["Cache-Control"] = "no-store"
120
+ return response
121
+
122
+
123
+ def _redirect_for(
124
+ request: HttpRequest,
125
+ app_label: str,
126
+ model_name: str,
127
+ pk: Any,
128
+ ) -> str:
129
+ """Construct a SPA-relative redirect (``<mount>/<app>/<model>/<pk>/``).
130
+
131
+ The mount is reconstructed from the request path. The URL pattern
132
+ is fixed inside this package, so everything in front of
133
+ ``api/v1/`` is the consumer-chosen prefix
134
+ (``ARCHITECTURE.md`` §4.5). Falls back to ``/`` if the pattern is
135
+ not present (should not happen — the URL router routed us here).
136
+ """
137
+ suffix = "api/v1/"
138
+ path = request.path
139
+ idx = path.rfind(suffix)
140
+ mount = path[:idx] if idx != -1 else "/"
141
+ return f"{mount}{app_label}/{model_name}/{pk}/"
@@ -0,0 +1,89 @@
1
+ """``DELETE /api/v1/<app>/<model>/<pk>/`` — destroy endpoint.
2
+
3
+ Wire contract: ``docs/api-contract.md`` §5.3.
4
+
5
+ Hard rules (`SECURITY.md` §3, `ACCEPTANCE.md` §3.1):
6
+
7
+ - Rule 3: Model resolved through ``admin.site._registry`` (B-7).
8
+ - Rule 5: ``has_delete_permission(request, obj)`` per-object gate.
9
+ - Rule 7: Calls ``ModelAdmin.delete_model(request, obj)`` — **never**
10
+ ``obj.delete()`` directly (B-4).
11
+ - Rule 10: Queryset starts at ``ModelAdmin.get_queryset(request)`` —
12
+ never ``Model.objects.all()`` (B-2).
13
+ - CSRF: No ``@csrf_exempt`` — Django's middleware enforces.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from typing import Any
19
+
20
+ from django.db import transaction
21
+ from django.http import HttpRequest
22
+ from django.http import HttpResponse
23
+ from django.views.generic import View
24
+
25
+ from django_admin_react.api.permissions import forbidden_response
26
+ from django_admin_react.api.permissions import is_admin_user
27
+ from django_admin_react.api.registry import get_admin_site
28
+ from django_admin_react.api.registry import resolve_model
29
+ from django_admin_react.api.writes import load_object_or_none
30
+ from django_admin_react.api.writes import not_found_response
31
+
32
+
33
+ class DestroyView(View):
34
+ """``DELETE /api/v1/<app_label>/<model_name>/<pk>/``.
35
+
36
+ The class name follows DRF's verb convention (``destroy``) to
37
+ avoid overloading the module surface — ``del`` is a Python
38
+ keyword and ``.delete()`` is a Django QuerySet/Model method, so
39
+ a *class* named ``DeleteView`` muddies imports. The HTTP-method
40
+ handler must still be named ``delete`` per Django's CBV
41
+ contract.
42
+ """
43
+
44
+ http_method_names = ["delete"]
45
+
46
+ def delete(
47
+ self,
48
+ request: HttpRequest,
49
+ app_label: str,
50
+ model_name: str,
51
+ pk: str,
52
+ *args: Any,
53
+ **kwargs: Any,
54
+ ) -> HttpResponse:
55
+ """Delete an instance (contract §5.3).
56
+
57
+ Gates: ``is_admin_user`` → ``resolve_model`` →
58
+ ``load_object_or_none`` (the admin's queryset is the only
59
+ lookup path — rule 10 / B-2) → ``has_delete_permission(request,
60
+ obj)`` per-object gate.
61
+
62
+ The actual delete goes through ``ModelAdmin.delete_model(request,
63
+ obj)`` — **never** ``obj.delete()`` — so any admin-side
64
+ cascade / hook logic is honored (rule 7 / B-4). Wrapped in
65
+ ``transaction.atomic()``. Returns a 204 (No Content) with
66
+ ``Cache-Control: no-store``.
67
+ """
68
+ admin_site = get_admin_site()
69
+ if not is_admin_user(request, admin_site=admin_site):
70
+ return forbidden_response()
71
+
72
+ resolved = resolve_model(admin_site, request, app_label, model_name)
73
+ if resolved is None:
74
+ return not_found_response()
75
+ model, model_admin = resolved
76
+
77
+ obj = load_object_or_none(model, model_admin, request, pk)
78
+ if obj is None:
79
+ return not_found_response()
80
+
81
+ if not model_admin.has_delete_permission(request, obj):
82
+ return forbidden_response()
83
+
84
+ with transaction.atomic():
85
+ model_admin.delete_model(request, obj)
86
+
87
+ response = HttpResponse(status=204)
88
+ response["Cache-Control"] = "no-store"
89
+ return response
@@ -0,0 +1,283 @@
1
+ """``GET /api/v1/<app>/<model>/<pk>/`` — single-object detail view.
2
+
3
+ Wire contract: ``docs/api-contract.md`` §4.
4
+
5
+ Hard rules (`SECURITY.md` §3, `ACCEPTANCE.md` §3.1):
6
+
7
+ - Rule 1: Staff + ``AdminSite.has_permission`` gate.
8
+ - Rule 3: Model resolved through ``admin.site._registry`` (B-7).
9
+ - Rule 5: ``has_view_permission(request, obj)`` per-object gate.
10
+ - Rule 6: Fields come from ``ModelAdmin.get_form(request, obj)`` /
11
+ ``get_fields`` / ``get_readonly_fields`` / ``get_exclude``.
12
+ Sensitive-name denylist applied on top
13
+ (``ACCEPTANCE.md`` §4.7 S-31).
14
+ - Rule 10: Queryset starts at ``ModelAdmin.get_queryset(request)`` —
15
+ never ``Model.objects.all()`` (B-2).
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from typing import Any
21
+
22
+ from django.contrib.admin.options import ModelAdmin
23
+ from django.contrib.admin.utils import label_for_field
24
+ from django.db.models import ForeignKey
25
+ from django.db.models import Model
26
+ from django.http import HttpRequest
27
+ from django.http import HttpResponse
28
+ from django.http import JsonResponse
29
+ from django.views.generic import View
30
+
31
+ from django_admin_react.api.permissions import forbidden_response
32
+ from django_admin_react.api.permissions import is_admin_user
33
+ from django_admin_react.api.registry import get_admin_site
34
+ from django_admin_react.api.registry import model_permissions
35
+ from django_admin_react.api.registry import resolve_model
36
+ from django_admin_react.api.serializers import field_metadata
37
+ from django_admin_react.api.serializers import filter_sensitive
38
+ from django_admin_react.api.serializers import is_sensitive_field_name
39
+ from django_admin_react.api.serializers import label_for
40
+ from django_admin_react.api.serializers import serialize_fk_value
41
+ from django_admin_react.api.serializers import serialize_value
42
+ from django_admin_react.api.writes import load_object_or_none
43
+ from django_admin_react.api.writes import not_found_response
44
+
45
+
46
+ class DetailView(View):
47
+ """``GET /api/v1/<app_label>/<model_name>/<pk>/`` — single object."""
48
+
49
+ http_method_names = ["get"]
50
+
51
+ def get(
52
+ self,
53
+ request: HttpRequest,
54
+ app_label: str,
55
+ model_name: str,
56
+ pk: str,
57
+ *args: Any,
58
+ **kwargs: Any,
59
+ ) -> HttpResponse:
60
+ """Return the full descriptor for one object (contract §4).
61
+
62
+ Gates, in order:
63
+
64
+ 1. ``is_admin_user`` — 403 if not authenticated active staff.
65
+ 2. ``resolve_model`` — 404 if model unknown or unviewable.
66
+ 3. ``load_object_or_none`` — 404 if pk doesn't resolve under
67
+ the admin's queryset (rule 10) or parse-fails.
68
+ 4. ``has_view_permission(request, obj)`` — per-object gate
69
+ (rule 5); 403 once we know the object exists but the user
70
+ may not see *this* row.
71
+
72
+ The payload includes the visible field set, fieldsets, the
73
+ four ``has_*_permission`` booleans, and a friendly label.
74
+ Excluded / readonly / sensitive-named fields are dropped by
75
+ the visibility filter (defense in depth on top of the admin
76
+ form).
77
+ """
78
+ admin_site = get_admin_site()
79
+ if not is_admin_user(request, admin_site=admin_site):
80
+ return forbidden_response()
81
+
82
+ resolved = resolve_model(admin_site, request, app_label, model_name)
83
+ if resolved is None:
84
+ return not_found_response()
85
+ model, model_admin = resolved
86
+
87
+ obj = load_object_or_none(model, model_admin, request, pk)
88
+ if obj is None:
89
+ return not_found_response()
90
+
91
+ if not model_admin.has_view_permission(request, obj):
92
+ return forbidden_response()
93
+
94
+ payload = _build_payload(model, model_admin, obj, request)
95
+ response = JsonResponse(payload, status=200)
96
+ # No-store: per-user, permission-gated payload must never be
97
+ # cached by intermediate proxies or the browser. Extends
98
+ # ACCEPTANCE.md §4.6 S-30 (defined for 4xx) to 200 responses.
99
+ response["Cache-Control"] = "no-store"
100
+ return response
101
+
102
+
103
+ # --------------------------------------------------------------------------- #
104
+ # Payload assembly #
105
+ # --------------------------------------------------------------------------- #
106
+ def _build_payload(
107
+ model: type[Model],
108
+ model_admin: ModelAdmin,
109
+ obj: Model,
110
+ request: HttpRequest,
111
+ ) -> dict[str, Any]:
112
+ """Compose the full detail response body (contract §4)."""
113
+ visible_names = _visible_field_names(model_admin, request, obj)
114
+ return {
115
+ "app_label": model._meta.app_label,
116
+ "model_name": model._meta.model_name,
117
+ "pk": obj.pk,
118
+ "label": label_for(obj),
119
+ "permissions": model_permissions(model_admin, request),
120
+ "fieldsets": _fieldsets_payload(model_admin, request, obj, visible_names),
121
+ "fields": _fields_payload(model, model_admin, obj, request, visible_names),
122
+ }
123
+
124
+
125
+ def _visible_field_names(
126
+ model_admin: ModelAdmin,
127
+ request: HttpRequest,
128
+ obj: Model,
129
+ ) -> list[str]:
130
+ """Field names the detail response may surface for this object.
131
+
132
+ This is the *read*-visible set: it includes readonly fields (so
133
+ the UI can render them) but drops admin-excluded fields and any
134
+ name matching the sensitive denylist. The *writable* set (for
135
+ POST/PATCH) lives in ``writes.writable_field_names`` and is
136
+ narrower.
137
+ """
138
+ declared = list(model_admin.get_fields(request, obj) or ())
139
+ excluded = set(model_admin.get_exclude(request, obj) or ())
140
+ visible = [
141
+ name for name in declared if name not in excluded and not is_sensitive_field_name(name)
142
+ ]
143
+ return filter_sensitive(visible)
144
+
145
+
146
+ def _fieldsets_payload(
147
+ model_admin: ModelAdmin,
148
+ request: HttpRequest,
149
+ obj: Model,
150
+ visible_names: list[str],
151
+ ) -> list[dict[str, Any]]:
152
+ """Honour ``ModelAdmin.get_fieldsets`` (with a flat fallback).
153
+
154
+ Fieldset entries that the admin lists but the visibility filter
155
+ drops are silently removed from the group. An empty result is
156
+ returned as the single "default" group so the SPA always has at
157
+ least one section to render.
158
+ """
159
+ try:
160
+ raw = model_admin.get_fieldsets(request, obj) or ()
161
+ except Exception:
162
+ raw = ()
163
+ if not raw:
164
+ return [{"title": None, "fields": visible_names}]
165
+
166
+ visible_set = set(visible_names)
167
+ payload: list[dict[str, Any]] = []
168
+ for title, opts in raw:
169
+ fields = [
170
+ sub
171
+ for entry in opts.get("fields", ())
172
+ for sub in (entry if isinstance(entry, list | tuple) else (entry,))
173
+ if sub in visible_set
174
+ ]
175
+ if fields:
176
+ payload.append({"title": title, "fields": fields})
177
+ return payload or [{"title": None, "fields": visible_names}]
178
+
179
+
180
+ def _fields_payload(
181
+ model: type[Model],
182
+ model_admin: ModelAdmin,
183
+ obj: Model,
184
+ request: HttpRequest,
185
+ visible_names: list[str],
186
+ ) -> dict[str, dict[str, Any]]:
187
+ """Build the per-field descriptor mapping (contract §4 ``fields``)."""
188
+ readonly = set(model_admin.get_readonly_fields(request, obj) or ())
189
+ form = model_admin.get_form(request, obj=obj)(instance=obj)
190
+
191
+ out: dict[str, dict[str, Any]] = {}
192
+ for name in visible_names:
193
+ out[name] = _descriptor_for(
194
+ model=model,
195
+ model_admin=model_admin,
196
+ obj=obj,
197
+ name=name,
198
+ form=form,
199
+ is_readonly=name in readonly,
200
+ )
201
+ return out
202
+
203
+
204
+ def _descriptor_for(
205
+ *,
206
+ model: type[Model],
207
+ model_admin: ModelAdmin,
208
+ obj: Model,
209
+ name: str,
210
+ form: Any,
211
+ is_readonly: bool,
212
+ ) -> dict[str, Any]:
213
+ """Per-field descriptor for one ``visible_names`` entry."""
214
+ model_field = _safe_get_field(model, name)
215
+ if model_field is None:
216
+ return _readonly_callable_descriptor(model_admin, model, obj, name)
217
+
218
+ if isinstance(model_field, ForeignKey):
219
+ value: Any = serialize_fk_value(getattr(obj, name, None))
220
+ else:
221
+ value = serialize_value(getattr(obj, name, None))
222
+
223
+ form_field = form.fields.get(name)
224
+ required = bool(form_field.required) if form_field is not None else False
225
+ help_text = getattr(model_field, "help_text", "") or (
226
+ form_field.help_text if form_field is not None else ""
227
+ )
228
+
229
+ return field_metadata(
230
+ model_field,
231
+ label=_field_label(model_admin, model, name),
232
+ required=required,
233
+ readonly=is_readonly,
234
+ help_text=str(help_text),
235
+ value=value,
236
+ )
237
+
238
+
239
+ def _readonly_callable_descriptor(
240
+ model_admin: ModelAdmin,
241
+ model: type[Model],
242
+ obj: Model,
243
+ name: str,
244
+ ) -> dict[str, Any]:
245
+ """Descriptor for a readonly callable / method (no underlying field).
246
+
247
+ ``ModelAdmin.get_fields`` may include method names or
248
+ ``@admin.display`` callables; those have no model field, so they
249
+ are surfaced as ``type=unsupported`` and ``readonly=True``.
250
+ """
251
+ value = getattr(obj, name, None)
252
+ if callable(value):
253
+ try:
254
+ value = value()
255
+ except Exception:
256
+ value = None
257
+ return {
258
+ "type": "unsupported",
259
+ "label": _field_label(model_admin, model, name),
260
+ "required": False,
261
+ "readonly": True,
262
+ "help_text": "",
263
+ "value": serialize_value(value),
264
+ }
265
+
266
+
267
+ # --------------------------------------------------------------------------- #
268
+ # Internals #
269
+ # --------------------------------------------------------------------------- #
270
+ def _safe_get_field(model: type[Model], name: str):
271
+ """Return ``model._meta.get_field(name)`` or ``None`` if not found."""
272
+ try:
273
+ return model._meta.get_field(name)
274
+ except Exception:
275
+ return None
276
+
277
+
278
+ def _field_label(model_admin: ModelAdmin, model: type[Model], name: str) -> str:
279
+ """Human-readable label for a field (Django's own helper, with fallback)."""
280
+ try:
281
+ return str(label_for_field(name, model, model_admin))
282
+ except Exception:
283
+ return name