django-admin-rest-api 0.1.0a0__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_rest_api/__init__.py +18 -0
- django_admin_rest_api/api/README.md +31 -0
- django_admin_rest_api/api/__init__.py +5 -0
- django_admin_rest_api/api/dates.py +215 -0
- django_admin_rest_api/api/filters.py +412 -0
- django_admin_rest_api/api/inlines.py +397 -0
- django_admin_rest_api/api/inlines_write.py +241 -0
- django_admin_rest_api/api/panels.py +113 -0
- django_admin_rest_api/api/permissions.py +132 -0
- django_admin_rest_api/api/registry.py +399 -0
- django_admin_rest_api/api/serializers.py +467 -0
- django_admin_rest_api/api/urls.py +170 -0
- django_admin_rest_api/api/views/README.md +22 -0
- django_admin_rest_api/api/views/__init__.py +6 -0
- django_admin_rest_api/api/views/actions.py +196 -0
- django_admin_rest_api/api/views/auth.py +185 -0
- django_admin_rest_api/api/views/autocomplete.py +163 -0
- django_admin_rest_api/api/views/bulk.py +248 -0
- django_admin_rest_api/api/views/create.py +214 -0
- django_admin_rest_api/api/views/create_form.py +229 -0
- django_admin_rest_api/api/views/delete_preview.py +107 -0
- django_admin_rest_api/api/views/destroy.py +93 -0
- django_admin_rest_api/api/views/detail.py +499 -0
- django_admin_rest_api/api/views/history.py +166 -0
- django_admin_rest_api/api/views/list.py +493 -0
- django_admin_rest_api/api/views/password.py +161 -0
- django_admin_rest_api/api/views/recent_actions.py +139 -0
- django_admin_rest_api/api/views/registry.py +53 -0
- django_admin_rest_api/api/views/schema.py +494 -0
- django_admin_rest_api/api/views/update.py +220 -0
- django_admin_rest_api/api/writes.py +541 -0
- django_admin_rest_api/apps.py +27 -0
- django_admin_rest_api/audit.py +58 -0
- django_admin_rest_api/conf.py +79 -0
- django_admin_rest_api/urls.py +28 -0
- django_admin_rest_api-0.1.0a0.dist-info/METADATA +251 -0
- django_admin_rest_api-0.1.0a0.dist-info/RECORD +40 -0
- django_admin_rest_api-0.1.0a0.dist-info/WHEEL +4 -0
- django_admin_rest_api-0.1.0a0.dist-info/licenses/LICENSE +21 -0
LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Martín Castro Álvarez and django-admin-rest-api 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,18 @@
|
|
|
1
|
+
"""django-admin-rest-api — a JSON REST API for the Django admin.
|
|
2
|
+
|
|
3
|
+
A frontend-agnostic Django app that exposes every ``ModelAdmin``
|
|
4
|
+
through JSON endpoints (list, detail, create, update, delete, actions,
|
|
5
|
+
history, autocomplete, …) using the **same permissions and the same
|
|
6
|
+
ModelAdmin source of truth** as the HTML admin.
|
|
7
|
+
|
|
8
|
+
This package adds no new features and no new permissions: it is the
|
|
9
|
+
wire surface that lets clients like
|
|
10
|
+
`django-admin-react <https://pypi.org/project/django-admin-react/>`_
|
|
11
|
+
and the forthcoming ``django-admin-mcp`` drive the Django admin over a
|
|
12
|
+
clean JSON contract.
|
|
13
|
+
|
|
14
|
+
See ``README.md`` for the install + URL wiring, and
|
|
15
|
+
``docs/api-contract.md`` (in the repo) for the wire shape.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
__version__ = "0.1.0a0"
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# django_admin_rest_api/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,215 @@
|
|
|
1
|
+
"""Date-hierarchy drill-down for the list endpoint.
|
|
2
|
+
|
|
3
|
+
Wire contract: ``docs/api-contract.md`` §3.1.
|
|
4
|
+
|
|
5
|
+
When a ``ModelAdmin`` declares ``date_hierarchy = "<field>"``, the list
|
|
6
|
+
endpoint:
|
|
7
|
+
|
|
8
|
+
1. Surfaces metadata (``field``, ``granularity_options``) in the
|
|
9
|
+
response.
|
|
10
|
+
2. Reads ``?year=`` / ``?month=`` / ``?day=`` query params and
|
|
11
|
+
narrows the queryset to that date window.
|
|
12
|
+
3. Returns ``buckets`` — the next-level drill-down counts the SPA
|
|
13
|
+
needs to render the admin's year → month → day strip.
|
|
14
|
+
|
|
15
|
+
Hard rules (`SECURITY.md` §3):
|
|
16
|
+
|
|
17
|
+
- Rule 10: Never starts from ``Model.objects.all()`` — the queryset
|
|
18
|
+
passed in is the one already produced by
|
|
19
|
+
``ModelAdmin.get_queryset(request)``.
|
|
20
|
+
- Rule 12: Garbage input (non-integer query params, out-of-range
|
|
21
|
+
values, unknown field) is silently ignored, never raises — a
|
|
22
|
+
hostile ``?year=abc`` must not produce a 500.
|
|
23
|
+
|
|
24
|
+
The helper is queryset-shape-agnostic: as long as the field is a
|
|
25
|
+
``DateField`` or ``DateTimeField`` on the model, the standard
|
|
26
|
+
``__year`` / ``__month`` / ``__day`` ORM lookups work.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
from typing import Any
|
|
32
|
+
from typing import Final
|
|
33
|
+
|
|
34
|
+
from django.contrib.admin.options import ModelAdmin
|
|
35
|
+
from django.db.models import Count
|
|
36
|
+
from django.db.models import QuerySet
|
|
37
|
+
from django.db.models.functions import Extract
|
|
38
|
+
from django.db.models.functions import ExtractDay
|
|
39
|
+
from django.db.models.functions import ExtractMonth
|
|
40
|
+
from django.db.models.functions import ExtractYear
|
|
41
|
+
from django.http import HttpRequest
|
|
42
|
+
|
|
43
|
+
GRANULARITY_OPTIONS: Final[tuple[str, ...]] = ("year", "month", "day")
|
|
44
|
+
|
|
45
|
+
# Sanity bounds for raw query-param ints. The field-level filter would
|
|
46
|
+
# also reject out-of-range values, but rejecting them up front avoids
|
|
47
|
+
# round-tripping garbage through the ORM.
|
|
48
|
+
_BOUNDS: Final[dict[str, tuple[int, int]]] = {
|
|
49
|
+
"year": (1, 9999),
|
|
50
|
+
"month": (1, 12),
|
|
51
|
+
"day": (1, 31),
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _parse_int(raw: str | None, key: str) -> int | None:
|
|
56
|
+
"""Parse one query-param int, or return ``None`` if missing/garbage.
|
|
57
|
+
|
|
58
|
+
Bounds-checks against ``_BOUNDS[key]`` so a hostile
|
|
59
|
+
``?month=99999999`` doesn't reach the ORM. Out-of-range values
|
|
60
|
+
are silently dropped, matching the package's "garbage in → safe
|
|
61
|
+
default" posture on query strings.
|
|
62
|
+
"""
|
|
63
|
+
if raw is None:
|
|
64
|
+
return None
|
|
65
|
+
try:
|
|
66
|
+
n = int(raw)
|
|
67
|
+
except (TypeError, ValueError):
|
|
68
|
+
return None
|
|
69
|
+
low, high = _BOUNDS[key]
|
|
70
|
+
if n < low or n > high:
|
|
71
|
+
return None
|
|
72
|
+
return n
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def parse_active(request: HttpRequest) -> dict[str, int | None]:
|
|
76
|
+
"""Extract the ``{year, month, day}`` selection from the query string.
|
|
77
|
+
|
|
78
|
+
Returns a dict with each key either an ``int`` or ``None``. The
|
|
79
|
+
SPA passes the active drill-down as plain query params:
|
|
80
|
+
``?year=2025``, ``?year=2025&month=10``, etc. Children of a
|
|
81
|
+
``None`` ancestor are dropped — e.g., ``?month=10`` without a
|
|
82
|
+
year is meaningless and becomes ``{year: None, month: None}``.
|
|
83
|
+
"""
|
|
84
|
+
year = _parse_int(request.GET.get("year"), "year")
|
|
85
|
+
month = _parse_int(request.GET.get("month"), "month") if year is not None else None
|
|
86
|
+
day = _parse_int(request.GET.get("day"), "day") if month is not None else None
|
|
87
|
+
return {"year": year, "month": month, "day": day}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def apply_filter(
|
|
91
|
+
queryset: QuerySet,
|
|
92
|
+
field: str,
|
|
93
|
+
active: dict[str, int | None],
|
|
94
|
+
) -> QuerySet:
|
|
95
|
+
"""Narrow ``queryset`` to the active year / month / day window.
|
|
96
|
+
|
|
97
|
+
Uses Django's ``__year`` / ``__month`` / ``__day`` lookups so the
|
|
98
|
+
filter delegates to the database engine (SQLite, Postgres, MySQL
|
|
99
|
+
all handle these natively). No raw SQL.
|
|
100
|
+
"""
|
|
101
|
+
lookups: dict[str, int] = {}
|
|
102
|
+
year, month, day = active.get("year"), active.get("month"), active.get("day")
|
|
103
|
+
if year is not None:
|
|
104
|
+
lookups[f"{field}__year"] = year
|
|
105
|
+
if month is not None:
|
|
106
|
+
lookups[f"{field}__month"] = month
|
|
107
|
+
if day is not None:
|
|
108
|
+
lookups[f"{field}__day"] = day
|
|
109
|
+
if not lookups:
|
|
110
|
+
return queryset
|
|
111
|
+
return queryset.filter(**lookups)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def build_buckets(
|
|
115
|
+
queryset: QuerySet,
|
|
116
|
+
field: str,
|
|
117
|
+
active: dict[str, int | None],
|
|
118
|
+
) -> list[dict[str, Any]]:
|
|
119
|
+
"""Build the next-level drill-down counts.
|
|
120
|
+
|
|
121
|
+
The level depends on the active selection:
|
|
122
|
+
|
|
123
|
+
- No year → buckets are years.
|
|
124
|
+
- Year, no month → buckets are months within that year.
|
|
125
|
+
- Year + month, no day → buckets are days within that month.
|
|
126
|
+
- Year + month + day → no further drill (returns ``[]``).
|
|
127
|
+
|
|
128
|
+
Each bucket is ``{value: int, count: int}``. The list is sorted
|
|
129
|
+
ascending by ``value``. ``NULL`` values in the date field are
|
|
130
|
+
excluded (they don't appear in any bucket).
|
|
131
|
+
"""
|
|
132
|
+
year, month, day = active.get("year"), active.get("month"), active.get("day")
|
|
133
|
+
extractor: type[Extract]
|
|
134
|
+
if year is None:
|
|
135
|
+
extractor, _name = ExtractYear, "year"
|
|
136
|
+
elif month is None:
|
|
137
|
+
extractor, _name = ExtractMonth, "month"
|
|
138
|
+
elif day is None:
|
|
139
|
+
extractor, _name = ExtractDay, "day"
|
|
140
|
+
else:
|
|
141
|
+
return []
|
|
142
|
+
|
|
143
|
+
rows = (
|
|
144
|
+
queryset.annotate(_bucket=extractor(field))
|
|
145
|
+
.values("_bucket")
|
|
146
|
+
.annotate(_count=Count("pk"))
|
|
147
|
+
.order_by("_bucket")
|
|
148
|
+
)
|
|
149
|
+
return [{"value": r["_bucket"], "count": r["_count"]} for r in rows if r["_bucket"] is not None]
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def date_hierarchy_payload(
|
|
153
|
+
model_admin: ModelAdmin,
|
|
154
|
+
queryset_before_filter: QuerySet,
|
|
155
|
+
queryset_after_filter: QuerySet, # noqa: ARG001 — reserved; see body
|
|
156
|
+
request: HttpRequest,
|
|
157
|
+
) -> dict[str, Any] | None:
|
|
158
|
+
"""Build the ``date_hierarchy`` block of the list response, or ``None``.
|
|
159
|
+
|
|
160
|
+
Returns ``None`` when:
|
|
161
|
+
|
|
162
|
+
- The admin does not declare ``date_hierarchy``, **or**
|
|
163
|
+
- The named field does not exist on the model (defensive — a
|
|
164
|
+
typo in the admin must not crash the list endpoint), **or**
|
|
165
|
+
- The named field is not a ``DateField`` / ``DateTimeField``.
|
|
166
|
+
|
|
167
|
+
Otherwise the payload is::
|
|
168
|
+
|
|
169
|
+
{
|
|
170
|
+
"field": "created_at",
|
|
171
|
+
"granularity_options": ["year", "month", "day"],
|
|
172
|
+
"active": {"year": 2025, "month": 10, "day": null},
|
|
173
|
+
"buckets": [
|
|
174
|
+
{"value": 1, "count": 12},
|
|
175
|
+
{"value": 2, "count": 4},
|
|
176
|
+
...
|
|
177
|
+
]
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
Buckets are computed from ``queryset_before_filter`` narrowed by
|
|
181
|
+
the *parent* levels of the active selection (not the current
|
|
182
|
+
level), so drilling from year 2025 still shows month buckets
|
|
183
|
+
*within 2025*.
|
|
184
|
+
"""
|
|
185
|
+
field_name = getattr(model_admin, "date_hierarchy", None)
|
|
186
|
+
if not field_name:
|
|
187
|
+
return None
|
|
188
|
+
|
|
189
|
+
model = model_admin.model
|
|
190
|
+
try:
|
|
191
|
+
field = model._meta.get_field(field_name)
|
|
192
|
+
except Exception: # pragma: no cover — typo'd date_hierarchy
|
|
193
|
+
return None
|
|
194
|
+
|
|
195
|
+
internal_type = field.get_internal_type()
|
|
196
|
+
if internal_type not in {"DateField", "DateTimeField"}:
|
|
197
|
+
return None
|
|
198
|
+
|
|
199
|
+
active = parse_active(request)
|
|
200
|
+
|
|
201
|
+
bucket_qs = queryset_before_filter
|
|
202
|
+
if active.get("year") is not None:
|
|
203
|
+
bucket_qs = bucket_qs.filter(**{f"{field_name}__year": active["year"]})
|
|
204
|
+
if active.get("month") is not None:
|
|
205
|
+
bucket_qs = bucket_qs.filter(**{f"{field_name}__month": active["month"]})
|
|
206
|
+
# day-level: no further buckets to compute.
|
|
207
|
+
|
|
208
|
+
buckets = build_buckets(bucket_qs, field_name, active)
|
|
209
|
+
|
|
210
|
+
return {
|
|
211
|
+
"field": field_name,
|
|
212
|
+
"granularity_options": list(GRANULARITY_OPTIONS),
|
|
213
|
+
"active": active,
|
|
214
|
+
"buckets": buckets,
|
|
215
|
+
}
|