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.
Files changed (40) hide show
  1. LICENSE +21 -0
  2. django_admin_rest_api/__init__.py +18 -0
  3. django_admin_rest_api/api/README.md +31 -0
  4. django_admin_rest_api/api/__init__.py +5 -0
  5. django_admin_rest_api/api/dates.py +215 -0
  6. django_admin_rest_api/api/filters.py +412 -0
  7. django_admin_rest_api/api/inlines.py +397 -0
  8. django_admin_rest_api/api/inlines_write.py +241 -0
  9. django_admin_rest_api/api/panels.py +113 -0
  10. django_admin_rest_api/api/permissions.py +132 -0
  11. django_admin_rest_api/api/registry.py +399 -0
  12. django_admin_rest_api/api/serializers.py +467 -0
  13. django_admin_rest_api/api/urls.py +170 -0
  14. django_admin_rest_api/api/views/README.md +22 -0
  15. django_admin_rest_api/api/views/__init__.py +6 -0
  16. django_admin_rest_api/api/views/actions.py +196 -0
  17. django_admin_rest_api/api/views/auth.py +185 -0
  18. django_admin_rest_api/api/views/autocomplete.py +163 -0
  19. django_admin_rest_api/api/views/bulk.py +248 -0
  20. django_admin_rest_api/api/views/create.py +214 -0
  21. django_admin_rest_api/api/views/create_form.py +229 -0
  22. django_admin_rest_api/api/views/delete_preview.py +107 -0
  23. django_admin_rest_api/api/views/destroy.py +93 -0
  24. django_admin_rest_api/api/views/detail.py +499 -0
  25. django_admin_rest_api/api/views/history.py +166 -0
  26. django_admin_rest_api/api/views/list.py +493 -0
  27. django_admin_rest_api/api/views/password.py +161 -0
  28. django_admin_rest_api/api/views/recent_actions.py +139 -0
  29. django_admin_rest_api/api/views/registry.py +53 -0
  30. django_admin_rest_api/api/views/schema.py +494 -0
  31. django_admin_rest_api/api/views/update.py +220 -0
  32. django_admin_rest_api/api/writes.py +541 -0
  33. django_admin_rest_api/apps.py +27 -0
  34. django_admin_rest_api/audit.py +58 -0
  35. django_admin_rest_api/conf.py +79 -0
  36. django_admin_rest_api/urls.py +28 -0
  37. django_admin_rest_api-0.1.0a0.dist-info/METADATA +251 -0
  38. django_admin_rest_api-0.1.0a0.dist-info/RECORD +40 -0
  39. django_admin_rest_api-0.1.0a0.dist-info/WHEEL +4 -0
  40. 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,5 @@
1
+ """JSON API for django_admin_rest_api.
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,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
+ }