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
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,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
|
+
]
|