django-admin-react 0.1.0a1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- LICENSE +21 -0
- django_admin_react/README.md +57 -0
- django_admin_react/__init__.py +10 -0
- django_admin_react/api/README.md +31 -0
- django_admin_react/api/__init__.py +5 -0
- django_admin_react/api/permissions.py +80 -0
- django_admin_react/api/registry.py +200 -0
- django_admin_react/api/serializers.py +183 -0
- django_admin_react/api/urls.py +84 -0
- django_admin_react/api/views/README.md +21 -0
- django_admin_react/api/views/__init__.py +6 -0
- django_admin_react/api/views/create.py +141 -0
- django_admin_react/api/views/destroy.py +89 -0
- django_admin_react/api/views/detail.py +283 -0
- django_admin_react/api/views/list.py +271 -0
- django_admin_react/api/views/registry.py +51 -0
- django_admin_react/api/views/update.py +121 -0
- django_admin_react/api/writes.py +325 -0
- django_admin_react/apps.py +27 -0
- django_admin_react/conf.py +77 -0
- django_admin_react/static/admin_react/.vite/manifest.json +11 -0
- django_admin_react/static/admin_react/assets/index-CKxeWYBA.css +1 -0
- django_admin_react/static/admin_react/assets/index-itk7hrnq.js +68 -0
- django_admin_react/static/admin_react/assets/index-itk7hrnq.js.map +1 -0
- django_admin_react/static/admin_react/index.html +13 -0
- django_admin_react/templates/admin_react/README.md +10 -0
- django_admin_react/templates/admin_react/index.html +33 -0
- django_admin_react/urls.py +39 -0
- django_admin_react/views.py +136 -0
- django_admin_react-0.1.0a1.dist-info/LICENSE +21 -0
- django_admin_react-0.1.0a1.dist-info/METADATA +237 -0
- django_admin_react-0.1.0a1.dist-info/RECORD +33 -0
- django_admin_react-0.1.0a1.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,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,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
|