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
LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Martín Castro Álvarez and django-admin-react contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,57 @@
1
+ # django_admin_react/ — the Python package
2
+
3
+ This directory **is** the artifact published to PyPI. Everything else
4
+ in the repository supports it.
5
+
6
+ ## Layout
7
+
8
+ ```
9
+ django_admin_react/
10
+ ├── __init__.py # __version__ and AppConfig entry point
11
+ ├── apps.py # Django AppConfig
12
+ ├── conf.py # settings.DJANGO_ADMIN_REACT lazy loader
13
+ ├── urls.py # mountable URL patterns (api/v1/ + SPA fallback)
14
+ ├── views.py # SPA index view
15
+ ├── api/
16
+ │ ├── __init__.py
17
+ │ ├── urls.py # API URL patterns
18
+ │ ├── permissions.py # IsStaffUser + ModelAdmin gates
19
+ │ ├── registry.py # AdminSite introspection helpers
20
+ │ ├── serializers.py # conservative field serialization
21
+ │ └── views/ # one file per endpoint (registry/list/detail/...)
22
+ ├── templates/admin_react/
23
+ │ └── index.html # SPA shell template
24
+ └── static/admin_react/ # built React bundle drops here
25
+ ```
26
+
27
+ ## Rules
28
+
29
+ - This package may **not** import from `frontend/`, `examples/`, or
30
+ `tests/`. It is self-contained.
31
+ - It may **not** import a consumer's models. All access goes through
32
+ `admin.site._registry` and `ModelAdmin` methods.
33
+ - It may **not** hardcode example model names (`Account`, `Book`,
34
+ `Transaction`, …). If you see one, fix it.
35
+ - Settings live under a single dict `settings.DJANGO_ADMIN_REACT`. Read
36
+ them through `django_admin_react.conf`, never via
37
+ `django.conf.settings.DJANGO_ADMIN_REACT` directly.
38
+
39
+ ## Implementation status
40
+
41
+ | File | Status | Lands in PR |
42
+ | ----------------------------- | ------------------ | ----------- |
43
+ | `__init__.py`, `apps.py` | Stub | #1 |
44
+ | `conf.py` | Stub w/ defaults | #1 → flesh out in #2 |
45
+ | `urls.py` | Routes wired, views stubbed | #1 → #6 |
46
+ | `views.py` | SpaIndexView stub | #6 |
47
+ | `api/permissions.py` | Empty | #3 |
48
+ | `api/registry.py` | Empty | #3 |
49
+ | `api/serializers.py` | Empty | #4 |
50
+ | `api/views/registry.py` | Empty | #3 |
51
+ | `api/views/list.py` | Empty | #4 |
52
+ | `api/views/detail.py` | Empty | #4 |
53
+ | `api/views/create.py` | Empty | #5 |
54
+ | `api/views/update.py` | Empty | #5 |
55
+ | `api/views/delete.py` | Empty | #5 |
56
+ | `templates/admin_react/index.html` | Placeholder | #6 |
57
+ | `static/admin_react/` | `.gitkeep` | populated at build time |
@@ -0,0 +1,10 @@
1
+ """django-admin-react — a React single-page admin for Django.
2
+
3
+ The actual implementation lands across PRs #2-#7. This package is
4
+ currently a skeleton that defines the layout and the Django AppConfig
5
+ entry point only. See `ARCHITECTURE.md` for the design contract.
6
+ """
7
+
8
+ __version__ = "0.0.0"
9
+
10
+ default_app_config = "django_admin_react.apps.DjangoAdminReactConfig"
@@ -0,0 +1,31 @@
1
+ # django_admin_react/api/
2
+
3
+ JSON API package. See [`/docs/api-contract.md`](../../docs/api-contract.md)
4
+ for the wire format and [`/ARCHITECTURE.md`](../../ARCHITECTURE.md) §4 for
5
+ the design.
6
+
7
+ ## Rules
8
+
9
+ - Every view consults `ModelAdmin` for permissions, queryset, form,
10
+ serialization. No exceptions.
11
+ - No direct `Model.objects.all()` — start from
12
+ `ModelAdmin.get_queryset(request)`.
13
+ - Client-provided `app_label`/`model_name` are resolved through
14
+ `admin.site._registry` only.
15
+ - CSRF on unsafe methods. Never exempt yourself.
16
+ - Conservative serializer with `str()` fallback (see
17
+ `serializers.py`).
18
+ - A denylist of sensitive-shaped field names is applied on top of the
19
+ admin form's own exclusion (defense in depth).
20
+
21
+ ## Layout
22
+
23
+ | File | Purpose |
24
+ | ----------------- | ------------------------------------------------------------ |
25
+ | `urls.py` | URL patterns for all API endpoints. |
26
+ | `permissions.py` | Staff + AdminSite.has_permission gate; per-op delegation. |
27
+ | `registry.py` | AdminSite introspection helpers. |
28
+ | `serializers.py` | Conservative field serialization + denylist. |
29
+ | `views/` | One module per endpoint. |
30
+
31
+ Implementation status is tracked in `../README.md`.
@@ -0,0 +1,5 @@
1
+ """JSON API for django_admin_react.
2
+
3
+ See `docs/api-contract.md` for the wire contract. All endpoints live
4
+ here; nothing in this subpackage may bypass ModelAdmin.
5
+ """
@@ -0,0 +1,80 @@
1
+ """Permission helpers.
2
+
3
+ The package's default permission gate is:
4
+
5
+ user.is_authenticated and user.is_active and user.is_staff
6
+ and admin_site.has_permission(request)
7
+
8
+ Per-operation gates always go through the relevant
9
+ ``ModelAdmin.has_*_permission(request, obj=None)`` method.
10
+
11
+ See ``SECURITY.md`` §3 (rules 1 and 12) for the contract this enforces.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import Final
17
+
18
+ from django.contrib.admin.sites import AdminSite
19
+ from django.http import HttpRequest
20
+ from django.http import HttpResponse
21
+ from django.http import JsonResponse
22
+
23
+ from django_admin_react.api.registry import get_admin_site
24
+
25
+
26
+ def _user_is_active_staff(request: HttpRequest) -> bool:
27
+ """Return True iff the request user is authenticated, active, and staff.
28
+
29
+ The triple check is intentional and each part is load-bearing.
30
+
31
+ - ``is_authenticated`` rejects ``AnonymousUser``.
32
+ - ``is_active`` ensures a deactivated account loses access immediately;
33
+ relying on ``is_staff`` alone would still let a disabled superuser
34
+ through.
35
+ - ``is_staff`` is the standard Django admin gate.
36
+
37
+ ``getattr(user, "is_active", False)`` (rather than ``user.is_active``)
38
+ is defensive: a custom user model might omit the attribute, and the
39
+ safe default is "no".
40
+ """
41
+ user = getattr(request, "user", None)
42
+ return bool(
43
+ user is not None
44
+ and user.is_authenticated
45
+ and getattr(user, "is_active", False)
46
+ and getattr(user, "is_staff", False)
47
+ )
48
+
49
+
50
+ def is_admin_user(request: HttpRequest, admin_site: AdminSite | None = None) -> bool:
51
+ """Return True iff the request may access the package's API.
52
+
53
+ The package's default policy is staff-only (rule 1 in ``SECURITY.md``
54
+ §3). ``AdminSite.has_permission`` is the consumer's escape hatch: if
55
+ a consumer's custom site allows non-staff users to access the admin,
56
+ this package follows that decision (`ARCHITECTURE.md` §4.2).
57
+ """
58
+ if not _user_is_active_staff(request):
59
+ return False
60
+ site = admin_site if admin_site is not None else get_admin_site()
61
+ return bool(site.has_permission(request))
62
+
63
+
64
+ _FORBIDDEN_BODY: Final[dict[str, dict[str, str]]] = {
65
+ "error": {"code": "forbidden", "message": "You do not have permission."}
66
+ }
67
+
68
+
69
+ def forbidden_response() -> HttpResponse:
70
+ """Return the package's canonical 403 envelope.
71
+
72
+ The body never includes data identifying the resource (per ``SECURITY.md``
73
+ §3 rule 12). For unauthenticated requests, callers may also want to
74
+ redirect to ``settings.LOGIN_URL`` — that's a caller-level choice and
75
+ not encoded here.
76
+ """
77
+ response = JsonResponse(_FORBIDDEN_BODY, status=403)
78
+ # Discourage caching of a permission decision.
79
+ response["Cache-Control"] = "no-store"
80
+ return response
@@ -0,0 +1,200 @@
1
+ """AdminSite introspection helpers.
2
+
3
+ The package looks up ``ModelAdmin`` instances **only** through the
4
+ configured admin site's ``_registry`` (rule 3 in ``SECURITY.md`` §3).
5
+ Client-provided ``app_label`` / ``model_name`` strings are never used
6
+ to ``import_string`` a model directly.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Iterable
12
+
13
+ from django.apps import apps
14
+ from django.contrib.admin.options import ModelAdmin
15
+ from django.contrib.admin.sites import AdminSite
16
+ from django.db.models import Model
17
+ from django.http import HttpRequest
18
+ from django.utils.module_loading import import_string
19
+
20
+
21
+ def get_admin_site() -> AdminSite:
22
+ """Resolve the configured admin site instance.
23
+
24
+ Configured via ``settings.DJANGO_ADMIN_REACT["ADMIN_SITE"]``;
25
+ defaults to ``django.contrib.admin.site``. Resolution is lazy: we
26
+ look up the dotted path each call so tests can override settings via
27
+ Django's standard ``override_settings`` decorator without having to
28
+ reload this module.
29
+ """
30
+ from django_admin_react import conf
31
+
32
+ dotted_path: str = conf.ADMIN_SITE
33
+ site = import_string(dotted_path)
34
+ if not isinstance(site, AdminSite):
35
+ raise TypeError(
36
+ "DJANGO_ADMIN_REACT['ADMIN_SITE'] must point to an AdminSite "
37
+ f"instance; got {type(site).__name__} at {dotted_path!r}."
38
+ )
39
+ return site
40
+
41
+
42
+ def iter_visible_models(
43
+ admin_site: AdminSite, request: HttpRequest
44
+ ) -> Iterable[tuple[type[Model], ModelAdmin]]:
45
+ """Yield (model, model_admin) pairs the request may view.
46
+
47
+ Filters by:
48
+
49
+ - ``ModelAdmin.has_module_permission(request)`` — gate per app.
50
+ - ``ModelAdmin.has_view_permission(request)`` — gate per model.
51
+
52
+ Both must return truthy. Order is the registration order in
53
+ ``_registry`` (Django preserves dict insertion order).
54
+ """
55
+ for model, model_admin in admin_site._registry.items():
56
+ if not model_admin.has_module_permission(request):
57
+ continue
58
+ if not model_admin.has_view_permission(request):
59
+ continue
60
+ yield model, model_admin
61
+
62
+
63
+ def _model_permissions(model_admin: ModelAdmin, request: HttpRequest) -> dict[str, bool]:
64
+ """The four ``has_*_permission`` answers, as plain booleans."""
65
+ return {
66
+ "view": bool(model_admin.has_view_permission(request)),
67
+ "add": bool(model_admin.has_add_permission(request)),
68
+ "change": bool(model_admin.has_change_permission(request)),
69
+ "delete": bool(model_admin.has_delete_permission(request)),
70
+ }
71
+
72
+
73
+ def _model_entry(model: type[Model], model_admin: ModelAdmin, request: HttpRequest) -> dict:
74
+ """Single ``models[]`` element for the registry response.
75
+
76
+ Wire shape is documented in ``docs/api-contract.md`` §2. Only
77
+ metadata + the four ``has_*_permission`` booleans go on the wire;
78
+ no model field schemas, no row counts — those are detail/list
79
+ endpoint responsibilities.
80
+ """
81
+ meta = model._meta
82
+ return {
83
+ "app_label": meta.app_label,
84
+ "model_name": meta.model_name,
85
+ "object_name": meta.object_name,
86
+ "verbose_name": str(meta.verbose_name),
87
+ "verbose_name_plural": str(meta.verbose_name_plural),
88
+ "permissions": _model_permissions(model_admin, request),
89
+ }
90
+
91
+
92
+ def _user_payload(request: HttpRequest) -> dict:
93
+ """``user`` block on the registry response (contract §2).
94
+
95
+ Exposes only data the user already knows about themselves: pk,
96
+ username, display name, ``is_staff``, ``is_superuser``. No email,
97
+ no group memberships, no permission codenames, no last-login
98
+ timestamp — the SPA does not need them and the registry endpoint
99
+ must stay deny-by-default (``SECURITY.md`` §3 rule 12).
100
+
101
+ ``getattr(user, "is_active", False)`` style defaults are used so
102
+ a custom user model missing an attribute degrades to "no" rather
103
+ than raising.
104
+ """
105
+ user = request.user
106
+ full_name = (user.get_full_name() or "").strip() if hasattr(user, "get_full_name") else ""
107
+ display_name = full_name or user.get_username()
108
+ return {
109
+ "id": user.pk,
110
+ "username": user.get_username(),
111
+ "is_staff": bool(getattr(user, "is_staff", False)),
112
+ "is_superuser": bool(getattr(user, "is_superuser", False)),
113
+ "display_name": display_name,
114
+ }
115
+
116
+
117
+ def _mount_from_request(request: HttpRequest) -> str:
118
+ """Best-effort recovery of the consumer-chosen mount prefix.
119
+
120
+ The view's URL pattern is fixed inside this package (``api/v1/registry/``),
121
+ so anything in front of that on ``request.path`` is the mount the
122
+ consumer configured (`ARCHITECTURE.md` §4.5).
123
+ """
124
+ suffix = "api/v1/registry/"
125
+ path = request.path
126
+ idx = path.rfind(suffix)
127
+ if idx == -1:
128
+ # Should not happen — the URL config routed us here. Fall back to '/'.
129
+ return "/"
130
+ return path[:idx] or "/"
131
+
132
+
133
+ def build_registry_payload(admin_site: AdminSite, request: HttpRequest) -> dict:
134
+ """Build the ``GET /api/v1/registry/`` response body.
135
+
136
+ The shape is documented in ``docs/api-contract.md`` §2.
137
+ """
138
+ apps_payload: dict[str, dict] = {}
139
+ for model, model_admin in iter_visible_models(admin_site, request):
140
+ app_label = model._meta.app_label
141
+ bucket = apps_payload.setdefault(
142
+ app_label,
143
+ {
144
+ "app_label": app_label,
145
+ "verbose_name": _app_verbose_name(app_label),
146
+ "models": [],
147
+ },
148
+ )
149
+ bucket["models"].append(_model_entry(model, model_admin, request))
150
+
151
+ return {
152
+ "mount": _mount_from_request(request),
153
+ "user": _user_payload(request),
154
+ "apps": list(apps_payload.values()),
155
+ }
156
+
157
+
158
+ def _app_verbose_name(app_label: str) -> str:
159
+ """Return the human-readable app name, falling back to the label."""
160
+ try:
161
+ return str(apps.get_app_config(app_label).verbose_name)
162
+ except LookupError:
163
+ return app_label
164
+
165
+
166
+ def resolve_model(
167
+ admin_site: AdminSite,
168
+ request: HttpRequest,
169
+ app_label: str,
170
+ model_name: str,
171
+ ) -> tuple[type[Model], ModelAdmin] | None:
172
+ """Look up a registered ``(model, model_admin)`` by client-given strings.
173
+
174
+ Client-provided ``app_label`` and ``model_name`` are **never** trusted.
175
+ They are resolved through ``AdminSite._registry`` (rule 3 in
176
+ ``SECURITY.md`` §3) and the resolution is gated by
177
+ ``has_module_permission`` and ``has_view_permission``.
178
+
179
+ Returns ``None`` if the model is not registered or the request is not
180
+ permitted to view it. The caller must convert that to a 404, per
181
+ ``ACCEPTANCE.md`` §3.1 B-7 and §4.3 S-11/S-12.
182
+ """
183
+ if not isinstance(app_label, str) or not isinstance(model_name, str):
184
+ return None
185
+ target = (app_label.lower(), model_name.lower())
186
+ for model, model_admin in admin_site._registry.items():
187
+ meta = model._meta
188
+ if (meta.app_label, meta.model_name) != target:
189
+ continue
190
+ if not model_admin.has_module_permission(request):
191
+ return None
192
+ if not model_admin.has_view_permission(request):
193
+ return None
194
+ return model, model_admin
195
+ return None
196
+
197
+
198
+ def model_permissions(model_admin: ModelAdmin, request: HttpRequest) -> dict[str, bool]:
199
+ """Public alias for the four ``has_*_permission`` booleans."""
200
+ return _model_permissions(model_admin, request)
@@ -0,0 +1,183 @@
1
+ """Conservative field serialization.
2
+
3
+ The wire format is described in ``docs/api-contract.md`` §4. This module
4
+ converts Python / Django values into the JSON payload, after the admin
5
+ form's exclusion rules have been applied. The sensitive-name denylist
6
+ below is defense-in-depth on top of those rules.
7
+
8
+ Rules (binding; see ``ACCEPTANCE.md`` §3.5 and §4.7):
9
+
10
+ - Pass-through: ``str``, ``int``, ``float``, ``bool``, ``None``.
11
+ - ``Decimal``, ``UUID``, ``date``, ``datetime``, ``time`` → string forms.
12
+ - ``ForeignKey`` → ``{"id": pk, "label": str(related)}``.
13
+ - ``ManyToMany`` → ``"unsupported"`` v1.
14
+ - Anything else → ``str(value)`` (never raises).
15
+ - Field names matching the denylist are never emitted.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import datetime as _dt
21
+ import decimal
22
+ import uuid
23
+ from collections.abc import Iterable
24
+ from typing import Any
25
+ from typing import Final
26
+
27
+ from django.db.models import Field
28
+ from django.db.models import ForeignKey
29
+ from django.db.models import ManyToManyField
30
+ from django.db.models import Model
31
+
32
+ SENSITIVE_NAME_SUBSTRINGS: Final[tuple[str, ...]] = (
33
+ "password",
34
+ "secret",
35
+ "token",
36
+ "api_key",
37
+ "apikey",
38
+ "hash",
39
+ "private_key",
40
+ "session",
41
+ "nonce",
42
+ "salt",
43
+ )
44
+
45
+
46
+ def is_sensitive_field_name(name: str) -> bool:
47
+ """Return True iff ``name`` matches any entry in the denylist."""
48
+ if not isinstance(name, str):
49
+ return True
50
+ lowered = name.lower()
51
+ return any(s in lowered for s in SENSITIVE_NAME_SUBSTRINGS)
52
+
53
+
54
+ def filter_sensitive(names: Iterable[str]) -> list[str]:
55
+ """Drop any field name that matches the denylist."""
56
+ return [n for n in names if not is_sensitive_field_name(n)]
57
+
58
+
59
+ def serialize_value(value: Any) -> Any:
60
+ """Convert a Python value to its JSON-compatible wire form."""
61
+ if value is None or isinstance(value, bool | int | float | str):
62
+ return value
63
+ if isinstance(value, decimal.Decimal):
64
+ return str(value)
65
+ if isinstance(value, uuid.UUID):
66
+ return str(value)
67
+ if isinstance(value, _dt.datetime):
68
+ return value.isoformat()
69
+ if isinstance(value, _dt.date):
70
+ return value.isoformat()
71
+ if isinstance(value, _dt.time):
72
+ return value.isoformat()
73
+ if isinstance(value, Model):
74
+ return {"id": value.pk, "label": label_for(value)}
75
+ return str(value)
76
+
77
+
78
+ def serialize_fk_value(value: Model | None) -> dict[str, Any] | None:
79
+ """Serialize an FK as ``{"id": pk, "label": str(obj)}`` or ``None``."""
80
+ if value is None:
81
+ return None
82
+ return {"id": value.pk, "label": label_for(value)}
83
+
84
+
85
+ def label_for(obj: Model) -> str:
86
+ """Return a human-readable label for ``obj`` (``str(obj)`` with fallback).
87
+
88
+ Django models that raise on ``__str__`` (e.g. missing related rows
89
+ during a half-migrated state) would otherwise crash a list page.
90
+ The fallback ``<ClassName: pk>`` keeps the UI responsive and never
91
+ raises.
92
+
93
+ Centralized here so the views, the registry payload, and the
94
+ serializer label objects identically — a UX win and a single
95
+ point of defense for ``__str__`` exceptions.
96
+ """
97
+ try:
98
+ return str(obj)
99
+ except Exception:
100
+ return f"<{obj.__class__.__name__}: {obj.pk}>"
101
+
102
+
103
+ _TYPE_BY_INTERNAL: Final[dict[str, str]] = {
104
+ "AutoField": "integer",
105
+ "BigAutoField": "integer",
106
+ "BigIntegerField": "integer",
107
+ "BooleanField": "boolean",
108
+ "CharField": "string",
109
+ "DateField": "date",
110
+ "DateTimeField": "datetime",
111
+ "DecimalField": "decimal",
112
+ "EmailField": "email",
113
+ "FloatField": "float",
114
+ "ForeignKey": "foreignkey",
115
+ "IntegerField": "integer",
116
+ "OneToOneField": "foreignkey",
117
+ "PositiveBigIntegerField": "integer",
118
+ "PositiveIntegerField": "integer",
119
+ "PositiveSmallIntegerField": "integer",
120
+ "SlugField": "slug",
121
+ "SmallIntegerField": "integer",
122
+ "TextField": "text",
123
+ "TimeField": "time",
124
+ "URLField": "url",
125
+ "UUIDField": "uuid",
126
+ }
127
+
128
+
129
+ def field_type_for(field: Field) -> str:
130
+ """Closed v1-vocabulary type for a Django model field."""
131
+ if isinstance(field, ManyToManyField):
132
+ return "unsupported"
133
+ internal = field.get_internal_type()
134
+ return _TYPE_BY_INTERNAL.get(internal, "unsupported")
135
+
136
+
137
+ def field_choices(field: Field) -> list[dict[str, Any]] | None:
138
+ """Serialize a Django field's ``choices`` as a list of ``{value, label}``.
139
+
140
+ Returns ``None`` when the field has no choices (so the wire payload
141
+ omits the key entirely rather than emitting a misleading empty
142
+ list). Labels are coerced via ``str(...)`` so lazy translation
143
+ proxies resolve to the request locale before serialization.
144
+ """
145
+ choices = getattr(field, "choices", None)
146
+ if not choices:
147
+ return None
148
+ return [{"value": v, "label": str(lbl)} for v, lbl in choices]
149
+
150
+
151
+ def field_metadata(
152
+ field: Field,
153
+ *,
154
+ label: str,
155
+ required: bool,
156
+ readonly: bool,
157
+ help_text: str,
158
+ value: Any,
159
+ ) -> dict[str, Any]:
160
+ """Per-field metadata block for the detail endpoint."""
161
+ type_ = field_type_for(field)
162
+ metadata: dict[str, Any] = {
163
+ "type": type_,
164
+ "label": label,
165
+ "required": required,
166
+ "readonly": readonly,
167
+ "help_text": help_text,
168
+ "value": value,
169
+ }
170
+ if isinstance(field, ForeignKey):
171
+ related = field.related_model
172
+ if related is not None:
173
+ meta = related._meta
174
+ metadata["to"] = {"app_label": meta.app_label, "model_name": meta.model_name}
175
+ if getattr(field, "max_length", None):
176
+ metadata["max_length"] = field.max_length
177
+ if type_ == "decimal":
178
+ metadata["decimal_places"] = getattr(field, "decimal_places", None)
179
+ choices = field_choices(field)
180
+ if choices is not None:
181
+ metadata["type"] = "choice"
182
+ metadata["choices"] = choices
183
+ return metadata
@@ -0,0 +1,84 @@
1
+ """URL patterns for the JSON API.
2
+
3
+ Mounted under the consumer's chosen prefix at ``api/v1/``. See
4
+ ``django_admin_react/urls.py`` and ``docs/api-contract.md`` for the
5
+ overall path layout.
6
+
7
+ Each path serves multiple HTTP methods via a thin dispatch class so the
8
+ per-method implementation files stay focused. CSRF protection is the
9
+ consumer's middleware (`SECURITY.md` §3 rule 4); no view here is
10
+ ``@csrf_exempt``.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import Any
16
+
17
+ from django.http import HttpRequest
18
+ from django.http import HttpResponse
19
+ from django.urls import path
20
+ from django.views.generic import View
21
+
22
+ from django_admin_react.api.views.create import CreateView
23
+ from django_admin_react.api.views.destroy import DestroyView
24
+ from django_admin_react.api.views.detail import DetailView
25
+ from django_admin_react.api.views.list import ListView
26
+ from django_admin_react.api.views.registry import RegistryView
27
+ from django_admin_react.api.views.update import UpdateView
28
+
29
+
30
+ class CollectionView(View):
31
+ """Dispatch GET → list, POST → create for ``/<app>/<model>/``.
32
+
33
+ The collection URL serves two HTTP verbs; rather than overloading
34
+ a single view module, we dispatch to dedicated per-verb views so
35
+ each verb's security gates and tests stay self-contained.
36
+ """
37
+
38
+ http_method_names = ["get", "post"]
39
+
40
+ def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
41
+ """Forward GET to ``ListView`` (contract §3)."""
42
+ return ListView.as_view()(request, *args, **kwargs)
43
+
44
+ def post(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
45
+ """Forward POST to ``CreateView`` (contract §5.1)."""
46
+ return CreateView.as_view()(request, *args, **kwargs)
47
+
48
+
49
+ class InstanceView(View):
50
+ """Dispatch GET / PATCH / DELETE for ``/<app>/<model>/<pk>/``.
51
+
52
+ Same pattern as :class:`CollectionView` — per-verb dispatch keeps
53
+ the security gates and tests for read / change / delete cleanly
54
+ separated.
55
+ """
56
+
57
+ http_method_names = ["get", "patch", "delete"]
58
+
59
+ def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
60
+ """Forward GET to ``DetailView`` (contract §4)."""
61
+ return DetailView.as_view()(request, *args, **kwargs)
62
+
63
+ def patch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
64
+ """Forward PATCH to ``UpdateView`` (contract §5.2)."""
65
+ return UpdateView.as_view()(request, *args, **kwargs)
66
+
67
+ def delete(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
68
+ """Forward DELETE to ``DestroyView`` (contract §5.3)."""
69
+ return DestroyView.as_view()(request, *args, **kwargs)
70
+
71
+
72
+ urlpatterns: list = [
73
+ path("registry/", RegistryView.as_view(), name="registry"),
74
+ path(
75
+ "<str:app_label>/<str:model_name>/",
76
+ CollectionView.as_view(),
77
+ name="collection",
78
+ ),
79
+ path(
80
+ "<str:app_label>/<str:model_name>/<str:pk>/",
81
+ InstanceView.as_view(),
82
+ name="instance",
83
+ ),
84
+ ]