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,271 @@
1
+ """GET /api/v1/<app>/<model>/ — list view.
2
+
3
+ Wire contract: ``docs/api-contract.md`` §3.
4
+
5
+ Hard rules followed (`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`` controls visibility.
10
+ - Rule 10: Queryset starts at ``ModelAdmin.get_queryset(request)`` —
11
+ never ``Model.objects.all()`` (B-2).
12
+ - Search: ``ModelAdmin.get_search_results(request, qs, q)``.
13
+ - Columns: ``ModelAdmin.get_list_display(request)``.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from typing import Any
19
+
20
+ from django.contrib.admin.options import ModelAdmin
21
+ from django.contrib.admin.utils import label_for_field
22
+ from django.contrib.admin.utils import lookup_field
23
+ from django.db.models import ForeignKey
24
+ from django.db.models import Model
25
+ from django.db.models import QuerySet
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 import conf
32
+ from django_admin_react.api.permissions import forbidden_response
33
+ from django_admin_react.api.permissions import is_admin_user
34
+ from django_admin_react.api.registry import get_admin_site
35
+ from django_admin_react.api.registry import model_permissions
36
+ from django_admin_react.api.registry import resolve_model
37
+ from django_admin_react.api.serializers import label_for
38
+ from django_admin_react.api.serializers import serialize_fk_value
39
+ from django_admin_react.api.serializers import serialize_value
40
+ from django_admin_react.api.writes import not_found_response
41
+
42
+
43
+ class ListView(View):
44
+ """``GET /api/v1/<app_label>/<model_name>/`` — paginated list."""
45
+
46
+ http_method_names = ["get"]
47
+
48
+ def get( # noqa: D401
49
+ self,
50
+ request: HttpRequest,
51
+ app_label: str,
52
+ model_name: str,
53
+ *args: Any,
54
+ **kwargs: Any,
55
+ ) -> HttpResponse:
56
+ """Return a paginated list of rows for one model (contract §3).
57
+
58
+ Gates, in order:
59
+
60
+ 1. ``is_admin_user`` — 403 if not authenticated active staff.
61
+ 2. ``resolve_model`` — 404 if the model isn't registered with
62
+ the admin site or the user can't view it. Returning 404
63
+ (not 403) is deliberate so the endpoint never reveals
64
+ "this model exists but you can't see it" (rule 12 /
65
+ ACCEPTANCE §4.3 S-11).
66
+ 3. ``ModelAdmin.get_queryset(request)`` provides the starting
67
+ queryset — never ``Model.objects.all()`` (rule 10 / B-2).
68
+
69
+ Then applies search, ordering, page-size clamp, and serializes
70
+ each row through the conservative serializer.
71
+ """
72
+ admin_site = get_admin_site()
73
+ if not is_admin_user(request, admin_site=admin_site):
74
+ return forbidden_response()
75
+
76
+ resolved = resolve_model(admin_site, request, app_label, model_name)
77
+ if resolved is None:
78
+ return not_found_response()
79
+ model, model_admin = resolved
80
+
81
+ queryset = model_admin.get_queryset(request)
82
+
83
+ q = request.GET.get("q", "") or ""
84
+ if q and model_admin.search_fields:
85
+ queryset, may_have_duplicates = model_admin.get_search_results(request, queryset, q)
86
+ if may_have_duplicates:
87
+ queryset = queryset.distinct()
88
+
89
+ queryset = _apply_ordering(queryset, model_admin, request)
90
+
91
+ total = queryset.count()
92
+
93
+ page_size = _clamp_page_size(request.GET.get("page_size"))
94
+ page = _clamp_page(request.GET.get("page"))
95
+ start = (page - 1) * page_size
96
+ end = start + page_size
97
+
98
+ list_display = list(model_admin.get_list_display(request))
99
+ columns = _columns_payload(model_admin, list_display)
100
+
101
+ results = [_row_for(obj, model_admin, list_display, request) for obj in queryset[start:end]]
102
+
103
+ body: dict[str, Any] = {
104
+ "app_label": model._meta.app_label,
105
+ "model_name": model._meta.model_name,
106
+ "permissions": model_permissions(model_admin, request),
107
+ "columns": columns,
108
+ "search_fields": list(model_admin.search_fields or ()),
109
+ "page": page,
110
+ "page_size": page_size,
111
+ "total": total,
112
+ "results": results,
113
+ }
114
+ response = JsonResponse(body, status=200)
115
+ # No-store: per-user, permission-gated payload must never be
116
+ # cached by intermediate proxies or the browser. Extends
117
+ # ACCEPTANCE.md §4.6 S-30 (defined for 4xx) to 200 responses.
118
+ response["Cache-Control"] = "no-store"
119
+ return response
120
+
121
+
122
+ def _clamp_page(raw: str | None) -> int:
123
+ """Parse ``?page=`` into a positive integer, defaulting to 1.
124
+
125
+ Garbage input (non-integer, negative, missing) returns 1. The
126
+ endpoint never raises on a bad query string — that would let a
127
+ crawler send "?page=abc" to trigger a 500.
128
+ """
129
+ try:
130
+ n = int(raw) if raw is not None else 1
131
+ except (TypeError, ValueError):
132
+ return 1
133
+ return max(1, n)
134
+
135
+
136
+ def _clamp_page_size(raw: str | None) -> int:
137
+ """Parse ``?page_size=`` and clamp to ``[1, conf.MAX_PAGE_SIZE]``.
138
+
139
+ The clamp is a denial-of-service guard: without an upper bound a
140
+ client could pass ``?page_size=10_000_000`` and force the
141
+ database to materialise ten million rows.
142
+ """
143
+ default = int(conf.DEFAULT_PAGE_SIZE)
144
+ maximum = int(conf.MAX_PAGE_SIZE)
145
+ try:
146
+ n = int(raw) if raw is not None else default
147
+ except (TypeError, ValueError):
148
+ n = default
149
+ if n < 1:
150
+ return default
151
+ return min(n, maximum)
152
+
153
+
154
+ def _apply_ordering(
155
+ queryset: QuerySet,
156
+ model_admin: ModelAdmin,
157
+ request: HttpRequest,
158
+ ) -> QuerySet:
159
+ """Apply ``?ordering=`` if every token is in the admin's allowed set.
160
+
161
+ Unknown tokens are silently dropped (per contract §7 and §3.4 C-5).
162
+ """
163
+ raw = request.GET.get("ordering", "")
164
+ if not raw:
165
+ return queryset
166
+ allowed = _allowed_ordering(model_admin, request)
167
+ tokens = []
168
+ for token in raw.split(","):
169
+ token = token.strip()
170
+ if not token:
171
+ continue
172
+ bare = token.lstrip("-")
173
+ if bare in allowed:
174
+ tokens.append(token)
175
+ if not tokens:
176
+ return queryset
177
+ return queryset.order_by(*tokens)
178
+
179
+
180
+ def _allowed_ordering(model_admin: ModelAdmin, request: HttpRequest) -> set[str]:
181
+ """Return the field-name set the admin allows for ordering.
182
+
183
+ ``ModelAdmin.get_sortable_by(request)`` (Django ≥ 2.1) is the
184
+ canonical source; falls back to ``list_display`` if not overridden.
185
+ """
186
+ get_sortable_by = getattr(model_admin, "get_sortable_by", None)
187
+ if callable(get_sortable_by):
188
+ return set(get_sortable_by(request) or ())
189
+ return set(model_admin.get_list_display(request) or ())
190
+
191
+
192
+ def _columns_payload(
193
+ model_admin: ModelAdmin,
194
+ list_display: list[str],
195
+ ) -> list[dict[str, Any]]:
196
+ """Build the ``columns[]`` payload for the list response.
197
+
198
+ Each entry has ``{name, label, sortable}``. Labels resolve through
199
+ Django's ``label_for_field`` so admin-customised labels (verbose
200
+ name, ``short_description``, etc.) are honored. The ``except``
201
+ fallback to the bare name is defensive — corrupt admin
202
+ registrations should never 500 the endpoint.
203
+ """
204
+ sortable = set(getattr(model_admin, "get_sortable_by", lambda r: ())(None) or ())
205
+ payload = []
206
+ for name in list_display:
207
+ try:
208
+ label = label_for_field(name, model_admin.model, model_admin)
209
+ except Exception: # pragma: no cover — defensive
210
+ label = name
211
+ payload.append(
212
+ {
213
+ "name": name,
214
+ "label": str(label),
215
+ "sortable": name in sortable,
216
+ }
217
+ )
218
+ return payload
219
+
220
+
221
+ def _row_for(
222
+ obj: Model,
223
+ model_admin: ModelAdmin,
224
+ list_display: list[str],
225
+ request: HttpRequest,
226
+ ) -> dict[str, Any]:
227
+ """Build one ``results[]`` entry for the list response.
228
+
229
+ Each row is ``{pk, label, fields: {name: serialized_value}}``.
230
+ Cell values go through ``lookup_field`` (so admin
231
+ ``@admin.display`` callables resolve correctly), then through
232
+ the conservative serializer with ``str()`` fallback. The except
233
+ branch is intentional — a misbehaving ``list_display`` callable
234
+ must not break the whole list response (graceful degrade).
235
+ """
236
+ fields: dict[str, Any] = {}
237
+ for name in list_display:
238
+ try:
239
+ _f, _attr, value = lookup_field(name, obj, model_admin)
240
+ except Exception: # pragma: no cover — defensive
241
+ value = ""
242
+ fields[name] = _serialize_list_value(obj, name, value)
243
+ return {"pk": obj.pk, "label": label_for(obj), "fields": fields}
244
+
245
+
246
+ def _serialize_list_value(obj: Model, name: str, value: Any) -> Any:
247
+ """Serialize a single ``list_display`` cell.
248
+
249
+ FK fields go through the FK envelope (``{"id", "label"}``);
250
+ everything else goes through the conservative serializer with
251
+ ``str()`` fallback. Callable list_display entries (e.g.
252
+ ``@admin.display``) have already been resolved to a plain value
253
+ by ``lookup_field``.
254
+ """
255
+ model_field = _safe_get_field(obj, name)
256
+ if isinstance(model_field, ForeignKey):
257
+ return serialize_fk_value(value)
258
+ return serialize_value(value)
259
+
260
+
261
+ def _safe_get_field(obj: Model, name: str):
262
+ """Return ``obj._meta.get_field(name)`` or ``None`` if not a real field.
263
+
264
+ ``list_display`` may contain method names or ``@admin.display``
265
+ properties; those have no model field. The caller (FK detection)
266
+ only cares about the case where a real field exists.
267
+ """
268
+ try:
269
+ return obj._meta.get_field(name)
270
+ except Exception:
271
+ return None
@@ -0,0 +1,51 @@
1
+ """GET /api/v1/registry/ — apps and models the user may see.
2
+
3
+ Wire contract: ``docs/api-contract.md`` §2.
4
+
5
+ Implementation rules followed (`SECURITY.md` §3):
6
+
7
+ - Rule 1: Default permission gate is staff + ``AdminSite.has_permission``.
8
+ - Rule 3: Models come exclusively from the configured admin site's
9
+ ``_registry`` — we never look at the global app registry.
10
+ - Rule 5: ``ModelAdmin.has_module_permission`` and ``has_view_permission``
11
+ decide visibility; we never invent our own gate.
12
+ - Rule 10: No ``Model.objects.all()`` is ever called from this view —
13
+ it doesn't read any model data at all.
14
+ - Rule 12: Failures return 403 with an opaque body, never 404.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from django.http import HttpRequest
20
+ from django.http import HttpResponse
21
+ from django.http import JsonResponse
22
+ from django.views.generic import View
23
+
24
+ from django_admin_react.api.permissions import forbidden_response
25
+ from django_admin_react.api.permissions import is_admin_user
26
+ from django_admin_react.api.registry import build_registry_payload
27
+ from django_admin_react.api.registry import get_admin_site
28
+
29
+
30
+ class RegistryView(View):
31
+ """``GET /api/v1/registry/`` — registry of visible apps and models."""
32
+
33
+ http_method_names = ["get"]
34
+
35
+ def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: # noqa: ARG002
36
+ """Return the registry payload (contract §2).
37
+
38
+ Hard gate: ``is_admin_user(request)`` (rule 1) → 403 envelope
39
+ if the request isn't authenticated active staff. Otherwise
40
+ builds the payload via :func:`build_registry_payload` and
41
+ attaches ``Cache-Control: no-store`` so the per-user payload
42
+ is never cached by intermediate proxies.
43
+ """
44
+ admin_site = get_admin_site()
45
+ if not is_admin_user(request, admin_site=admin_site):
46
+ return forbidden_response()
47
+ response = JsonResponse(build_registry_payload(admin_site, request), status=200)
48
+ # Never let an intermediate proxy or browser cache cross-user
49
+ # data (extends ACCEPTANCE.md §4.6 S-30 to 200 responses).
50
+ response["Cache-Control"] = "no-store"
51
+ return response
@@ -0,0 +1,121 @@
1
+ """``PATCH /api/v1/<app>/<model>/<pk>/`` — partial update endpoint.
2
+
3
+ Wire contract: ``docs/api-contract.md`` §5.2.
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_change_permission(request, obj)`` per-object gate.
9
+ - Rule 6: Writes go through ``ModelAdmin.get_form()`` then
10
+ ``save_model(..., change=True)`` (B-3).
11
+ - Rule 10: Queryset starts at ``ModelAdmin.get_queryset(request)`` —
12
+ never ``Model.objects.all()`` (B-2).
13
+ - Rule 12: Writes to ``readonly`` / ``exclude`` keys → 400 (S-31, B-3).
14
+ - CSRF: No ``@csrf_exempt`` — Django's middleware enforces.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from typing import Any
20
+
21
+ from django.db import transaction
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 resolve_model
31
+ from django_admin_react.api.views.detail import _build_payload
32
+ from django_admin_react.api.writes import form_errors_to_envelope
33
+ from django_admin_react.api.writes import load_object_or_none
34
+ from django_admin_react.api.writes import merged_initial_for_update
35
+ from django_admin_react.api.writes import not_found_response
36
+ from django_admin_react.api.writes import parse_json_body
37
+ from django_admin_react.api.writes import readonly_or_excluded_names
38
+ from django_admin_react.api.writes import reject_forbidden_keys
39
+ from django_admin_react.api.writes import validation_failed
40
+ from django_admin_react.api.writes import writable_field_names
41
+
42
+
43
+ class UpdateView(View):
44
+ """``PATCH /api/v1/<app_label>/<model_name>/<pk>/``."""
45
+
46
+ http_method_names = ["patch"]
47
+
48
+ def patch(
49
+ self,
50
+ request: HttpRequest,
51
+ app_label: str,
52
+ model_name: str,
53
+ pk: str,
54
+ *args: Any,
55
+ **kwargs: Any,
56
+ ) -> HttpResponse:
57
+ """Partially update an instance (contract §5.2).
58
+
59
+ PATCH semantics: any field the payload omits keeps its
60
+ current value. The implementation builds form ``initial``
61
+ data by overlaying the payload on the instance's current
62
+ values, then runs ``ModelAdmin.get_form()`` exactly like the
63
+ Django admin change view.
64
+
65
+ Gates: ``is_admin_user`` → ``resolve_model`` →
66
+ ``load_object_or_none`` (uses the admin's queryset, never
67
+ ``Model.objects.all()``) → ``has_change_permission(request,
68
+ obj)`` per-object gate (rule 5).
69
+
70
+ Same payload-shape validation as create (unknown / readonly /
71
+ excluded / sensitive keys → 400). Write path goes through
72
+ ``form.save(commit=False)`` →
73
+ ``model_admin.save_model(..., change=True)`` (rule 6 / B-3),
74
+ wrapped in ``transaction.atomic()``.
75
+ """
76
+ admin_site = get_admin_site()
77
+ if not is_admin_user(request, admin_site=admin_site):
78
+ return forbidden_response()
79
+
80
+ resolved = resolve_model(admin_site, request, app_label, model_name)
81
+ if resolved is None:
82
+ return not_found_response()
83
+ model, model_admin = resolved
84
+
85
+ obj = load_object_or_none(model, model_admin, request, pk)
86
+ if obj is None:
87
+ return not_found_response()
88
+
89
+ if not model_admin.has_change_permission(request, obj):
90
+ return forbidden_response()
91
+
92
+ parsed = parse_json_body(request)
93
+ if isinstance(parsed, HttpResponse):
94
+ return parsed
95
+ payload: dict[str, Any] = parsed
96
+
97
+ writable = writable_field_names(model, model_admin, request, obj)
98
+ forbidden = readonly_or_excluded_names(model_admin, request, obj)
99
+ rejection = reject_forbidden_keys(payload, writable, forbidden)
100
+ if rejection is not None:
101
+ return rejection
102
+
103
+ form = model_admin.get_form(request, obj=obj)(
104
+ data=merged_initial_for_update(obj, writable, payload, model),
105
+ files=None,
106
+ instance=obj,
107
+ )
108
+ if not form.is_valid():
109
+ return validation_failed(form_errors_to_envelope(form))
110
+
111
+ with transaction.atomic():
112
+ instance = form.save(commit=False)
113
+ model_admin.save_model(request, instance, form, change=True)
114
+ form.save_m2m()
115
+
116
+ response = JsonResponse(
117
+ _build_payload(model, model_admin, instance, request),
118
+ status=200,
119
+ )
120
+ response["Cache-Control"] = "no-store"
121
+ return response