Skip to content

Commit c58fdc0

Browse files
MartinCastroAlvarezmartin-castro-laminr-aiclaude
authored
feat(api): object history read endpoint — Django admin parity (#155 read half) (#162)
Adds GET /api/v1/<app>/<model>/<pk>/history/ — the LogEntry timeline the legacy admin's History button shows. Completes #155 backend (the emit half landed in #158); the SPA timeline UI is the remaining frontend slot. - `audit.py` (NEW, top-level) — `object_log_entries(obj)` returns the LogEntry queryset (newest-first, user pre-fetched). Lives OUTSIDE `api/` on purpose: LogEntry is Django's own framework audit table, not a consumer model, so the `get_queryset` rule (B-2 / S-15) is categorically inapplicable. Django's own `history_view` reads it the same way. Keeping it in a dedicated single-responsibility module makes the distinction explicit rather than special-casing it inside the consumer-model API layer. - `api/views/history.py` (NEW) — `HistoryView`: staff gate → resolve_model → object loaded via get_queryset → per-object has_view_permission → paginated timeline. 404 (no oracle) on missing/unviewable. `Cache-Control: no-store`. - `api/urls.py` — `history/` route before the instance pattern (same ordering caveat as panel/). Wire shape: `{object, entries:[{id, action, action_time, user, change_message_human, change_message_structured}], page, page_size, total, num_pages}`. Tests: tests/test_history.py — full auth matrix (anon/non-staff/ unregistered/bogus-pk/no-view-perm) + empty history, reflects-SPA- write, newest-first pagination, structured-message surfacing. 9 cases. Full suite 298 → 303. Parity: ACCEPTANCE.md §2.9 E-18 (read half). Co-authored-by: Martin Castro Laminrs <mcastro@laminr.ai> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent cf14d80 commit c58fdc0

4 files changed

Lines changed: 359 additions & 0 deletions

File tree

django_admin_react/api/urls.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
from django_admin_react.api.views.create import CreateView
2727
from django_admin_react.api.views.destroy import DestroyView
2828
from django_admin_react.api.views.detail import DetailView
29+
from django_admin_react.api.views.history import HistoryView
2930
from django_admin_react.api.views.list import ListView
3031
from django_admin_react.api.views.registry import RegistryView
3132
from django_admin_react.api.views.schema import SchemaView
@@ -112,6 +113,14 @@ def delete(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpRespons
112113
PanelView.as_view(),
113114
name="panel",
114115
),
116+
# History sub-resource (#155) — LogEntry timeline for one object.
117+
# Must precede the instance pattern so ``/history/`` isn't
118+
# swallowed as part of the ``<pk>`` route.
119+
path(
120+
"<str:app_label>/<str:model_name>/<str:pk>/history/",
121+
HistoryView.as_view(),
122+
name="history",
123+
),
115124
path(
116125
"<str:app_label>/<str:model_name>/<str:pk>/",
117126
InstanceView.as_view(),
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
"""``GET /api/v1/<app>/<model>/<pk>/history/`` — object history.
2+
3+
Wire contract: ``docs/api-contract.md`` §4 (history sub-resource).
4+
5+
Surfaces the ``django.contrib.admin.models.LogEntry`` timeline for a
6+
single object — the same data the legacy admin's *History* button
7+
shows. Parity (#155): a Django dev's audit trail must be reachable
8+
from the SPA, and the entries the SPA itself writes (via the create /
9+
update / delete endpoints, which call ``ModelAdmin.log_*``) show up
10+
here alongside any earlier HTML-admin entries.
11+
12+
Hard rules (`SECURITY.md` §3):
13+
14+
- Rule 1: Staff + ``AdminSite.has_permission`` gate.
15+
- Rule 3: Model resolved through ``admin.site._registry`` (B-7).
16+
- Rule 5: Per-object ``has_view_permission`` gate — you can only read
17+
the history of an object you can view.
18+
- Rule 10: Object loaded through ``ModelAdmin.get_queryset(request)``
19+
— never ``Model.objects.all()`` (B-2).
20+
- CSRF: GET is safe; no state change.
21+
"""
22+
23+
from __future__ import annotations
24+
25+
from typing import Any
26+
27+
from django.contrib.admin.models import ADDITION
28+
from django.contrib.admin.models import CHANGE
29+
from django.contrib.admin.models import DELETION
30+
from django.contrib.admin.models import LogEntry
31+
from django.core.paginator import Paginator
32+
from django.http import HttpRequest
33+
from django.http import HttpResponse
34+
from django.http import JsonResponse
35+
from django.views.generic import View
36+
37+
from django_admin_react.api.permissions import forbidden_response
38+
from django_admin_react.api.permissions import is_admin_user
39+
from django_admin_react.api.registry import get_admin_site
40+
from django_admin_react.api.registry import resolve_model
41+
from django_admin_react.api.serializers import label_for
42+
from django_admin_react.api.writes import load_object_or_none
43+
from django_admin_react.api.writes import not_found_response
44+
from django_admin_react.audit import object_log_entries
45+
46+
_ACTION_LABELS = {ADDITION: "addition", CHANGE: "change", DELETION: "deletion"}
47+
48+
_DEFAULT_PAGE_SIZE = 25
49+
_MAX_PAGE_SIZE = 200
50+
51+
52+
class HistoryView(View):
53+
"""``GET /api/v1/<app_label>/<model_name>/<pk>/history/``."""
54+
55+
http_method_names = ["get"]
56+
57+
def get(
58+
self,
59+
request: HttpRequest,
60+
app_label: str,
61+
model_name: str,
62+
pk: str,
63+
*args: Any,
64+
**kwargs: Any,
65+
) -> HttpResponse:
66+
"""Return the paginated ``LogEntry`` timeline for one object.
67+
68+
Gates: ``is_admin_user`` → ``resolve_model`` → object loaded
69+
through ``get_queryset`` → ``has_view_permission(obj)``. A
70+
missing object or unviewable object both return the canonical
71+
404 (no oracle distinguishing "doesn't exist" from "you can't
72+
see it" — ``SECURITY.md`` §3 rule 12).
73+
"""
74+
admin_site = get_admin_site()
75+
if not is_admin_user(request, admin_site=admin_site):
76+
return forbidden_response(request)
77+
78+
resolved = resolve_model(admin_site, request, app_label, model_name)
79+
if resolved is None:
80+
return not_found_response()
81+
model, model_admin = resolved
82+
83+
obj = load_object_or_none(model, model_admin, request, pk)
84+
if obj is None:
85+
return not_found_response()
86+
87+
if not model_admin.has_view_permission(request, obj):
88+
return forbidden_response(request)
89+
90+
entries = object_log_entries(obj)
91+
92+
paginator = Paginator(entries, _page_size(request))
93+
page_number = _page_number(request)
94+
page = paginator.get_page(page_number)
95+
96+
body = {
97+
"object": {"pk": obj.pk, "label": label_for(obj)},
98+
"entries": [_serialize_entry(e) for e in page.object_list],
99+
"page": page.number,
100+
"page_size": paginator.per_page,
101+
"total": paginator.count,
102+
"num_pages": paginator.num_pages,
103+
}
104+
response = JsonResponse(body, status=200)
105+
response["Cache-Control"] = "no-store"
106+
return response
107+
108+
109+
def _serialize_entry(entry: LogEntry) -> dict[str, Any]:
110+
"""One ``LogEntry`` → wire shape.
111+
112+
``change_message_human`` is Django's own rendered summary
113+
(``get_change_message``); ``change_message_structured`` is the raw
114+
JSON list so a SPA can render field-level detail without re-parsing
115+
the prose.
116+
"""
117+
user = entry.user
118+
return {
119+
"id": entry.id,
120+
"action": _ACTION_LABELS.get(entry.action_flag, "unknown"),
121+
"action_time": entry.action_time.isoformat(),
122+
"user": None if user is None else {"id": user.pk, "label": str(user)},
123+
"change_message_human": entry.get_change_message(),
124+
"change_message_structured": _structured_message(entry),
125+
}
126+
127+
128+
def _structured_message(entry: LogEntry) -> Any:
129+
"""Return the raw structured change message, or ``[]`` if absent.
130+
131+
``LogEntry.change_message`` is a JSON string for entries written by
132+
modern admin; older / hand-written entries may store free text.
133+
``get_change_message`` already handles the prose rendering, so here
134+
we only surface the structured form when it parses as a list.
135+
"""
136+
import json
137+
138+
raw = entry.change_message or ""
139+
try:
140+
parsed = json.loads(raw)
141+
except (ValueError, TypeError):
142+
return []
143+
return parsed if isinstance(parsed, list) else []
144+
145+
146+
def _page_size(request: HttpRequest) -> int:
147+
"""Clamp the ``page_size`` query param to ``[1, _MAX_PAGE_SIZE]``."""
148+
raw = request.GET.get("page_size")
149+
if raw is None:
150+
return _DEFAULT_PAGE_SIZE
151+
try:
152+
value = int(raw)
153+
except (TypeError, ValueError):
154+
return _DEFAULT_PAGE_SIZE
155+
return max(1, min(value, _MAX_PAGE_SIZE))
156+
157+
158+
def _page_number(request: HttpRequest) -> int:
159+
"""Read the ``page`` query param; default 1 on absent / bogus."""
160+
raw = request.GET.get("page")
161+
try:
162+
return max(1, int(raw))
163+
except (TypeError, ValueError):
164+
return 1

django_admin_react/audit.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
"""Access to Django's admin audit log (``LogEntry``).
2+
3+
This module is deliberately **outside** ``django_admin_react/api/``.
4+
The ``api/`` package obeys the hard rule (``SECURITY.md`` §3 rule 10 /
5+
``ACCEPTANCE.md`` §3.1 B-2): every **consumer-model** queryset starts
6+
from ``ModelAdmin.get_queryset(request)``, never ``Model.objects.*``.
7+
8+
``django.contrib.admin.models.LogEntry`` is **not** a consumer model —
9+
it is Django's own framework audit table, and Django's own
10+
``ModelAdmin.history_view`` reads it via ``LogEntry.objects.filter(...)``
11+
directly. The get_queryset rule is categorically inapplicable to it.
12+
Keeping the LogEntry access here, in its own single-responsibility
13+
module, makes that distinction explicit at the file-system level rather
14+
than burying a special case inside the consumer-model API layer.
15+
16+
Public surface:
17+
18+
- :func:`object_log_entries` — the ``LogEntry`` queryset for one object,
19+
newest-first, with the acting user pre-fetched.
20+
"""
21+
22+
from __future__ import annotations
23+
24+
from django.contrib.admin.models import LogEntry
25+
from django.contrib.contenttypes.models import ContentType
26+
from django.db.models import Model
27+
from django.db.models import QuerySet
28+
29+
30+
def object_log_entries(obj: Model) -> QuerySet[LogEntry]:
31+
"""Return the ``LogEntry`` rows for ``obj``, newest action first.
32+
33+
Scoped by the object's ``ContentType`` + ``object_id`` — the same
34+
pair Django's admin ``history_view`` uses. ``select_related("user")``
35+
so the timeline serializer doesn't N+1 on the acting user.
36+
"""
37+
content_type = ContentType.objects.get_for_model(type(obj))
38+
return (
39+
LogEntry.objects.filter(content_type=content_type, object_id=str(obj.pk))
40+
.select_related("user")
41+
.order_by("-action_time")
42+
)

tests/test_history.py

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
"""Tests for ``GET /api/v1/<app>/<model>/<pk>/history/`` (#155 read half).
2+
3+
Mandatory matrix from ``CLAUDE.md`` §6 + feature-specific: the
4+
timeline reflects entries the SPA write endpoints emit, ordered
5+
newest-first, paginated, gated by per-object view permission.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import json
11+
12+
import pytest
13+
from django.contrib.admin.models import CHANGE
14+
from django.contrib.admin.models import LogEntry
15+
from django.contrib.auth.models import Group
16+
from django.contrib.contenttypes.models import ContentType
17+
from django.test import Client
18+
19+
from tests.helpers import admin_override
20+
21+
COLLECTION_URL = "/admin-react/api/v1/auth/group/"
22+
23+
24+
def _history_url(pk: int) -> str:
25+
return f"{COLLECTION_URL}{pk}/history/"
26+
27+
28+
def _log(group: Group, user, action=CHANGE, message="[]") -> None:
29+
# Create the row directly — the manager's ``log_action`` is
30+
# deprecated in Django 5.2 and the suite treats warnings as errors.
31+
LogEntry.objects.create(
32+
user_id=user.pk,
33+
content_type=ContentType.objects.get_for_model(Group),
34+
object_id=str(group.pk),
35+
object_repr=str(group),
36+
action_flag=action,
37+
change_message=message,
38+
)
39+
40+
41+
# --------------------------------------------------------------------------- #
42+
# Mandatory matrix #
43+
# --------------------------------------------------------------------------- #
44+
@pytest.mark.django_db
45+
def test_anonymous_unauthorized(anon_client: Client) -> None:
46+
g = Group.objects.create(name="g")
47+
response = anon_client.get(_history_url(g.pk))
48+
assert response.status_code in (302, 403)
49+
50+
51+
@pytest.mark.django_db
52+
def test_non_staff_forbidden(user_client: Client) -> None:
53+
g = Group.objects.create(name="g")
54+
response = user_client.get(_history_url(g.pk))
55+
assert response.status_code == 403
56+
57+
58+
@pytest.mark.django_db
59+
def test_unregistered_model_not_found(superuser_client: Client) -> None:
60+
response = superuser_client.get("/admin-react/api/v1/auth/nope/1/history/")
61+
assert response.status_code == 404
62+
63+
64+
@pytest.mark.django_db
65+
def test_bogus_pk_not_found(superuser_client: Client) -> None:
66+
response = superuser_client.get(_history_url(999999))
67+
assert response.status_code == 404
68+
69+
70+
@pytest.mark.django_db
71+
def test_staff_without_view_permission_forbidden(superuser_client: Client) -> None:
72+
g = Group.objects.create(name="g")
73+
with admin_override(Group, has_view_permission=lambda self, request, obj=None: False):
74+
response = superuser_client.get(_history_url(g.pk))
75+
assert response.status_code in (403, 404)
76+
77+
78+
# --------------------------------------------------------------------------- #
79+
# Feature behaviour #
80+
# --------------------------------------------------------------------------- #
81+
@pytest.mark.django_db
82+
def test_empty_history_returns_empty_list(superuser_client: Client) -> None:
83+
g = Group.objects.create(name="fresh")
84+
response = superuser_client.get(_history_url(g.pk))
85+
assert response.status_code == 200
86+
body = response.json()
87+
assert body["object"]["pk"] == g.pk
88+
assert body["entries"] == []
89+
assert body["total"] == 0
90+
assert response["Cache-Control"] == "no-store"
91+
92+
93+
@pytest.mark.django_db
94+
def test_history_reflects_spa_write(superuser_client: Client) -> None:
95+
# Create through the SPA endpoint → should emit an ADDITION entry
96+
# (via the create view's log_addition), visible in the timeline.
97+
created = superuser_client.post(
98+
COLLECTION_URL,
99+
data=json.dumps({"name": "logged"}),
100+
content_type="application/json",
101+
)
102+
pk = created.json()["pk"]
103+
response = superuser_client.get(_history_url(pk))
104+
assert response.status_code == 200
105+
body = response.json()
106+
assert body["total"] >= 1
107+
assert body["entries"][0]["action"] == "addition"
108+
assert body["entries"][0]["user"] is not None
109+
110+
111+
@pytest.mark.django_db
112+
def test_history_newest_first_and_paginated(superuser_client, django_user_model) -> None:
113+
g = Group.objects.create(name="g")
114+
user = django_user_model.objects.filter(is_superuser=True).first()
115+
for i in range(30):
116+
_log(g, user, message=json.dumps([{"changed": {"fields": [f"f{i}"]}}]))
117+
118+
# Page 1, default size 25.
119+
r1 = superuser_client.get(_history_url(g.pk))
120+
b1 = r1.json()
121+
assert b1["total"] == 30
122+
assert b1["page"] == 1
123+
assert b1["page_size"] == 25
124+
assert len(b1["entries"]) == 25
125+
# Newest-first: ids are descending.
126+
ids = [e["id"] for e in b1["entries"]]
127+
assert ids == sorted(ids, reverse=True)
128+
129+
# Page 2 has the remaining 5.
130+
r2 = superuser_client.get(_history_url(g.pk) + "?page=2")
131+
b2 = r2.json()
132+
assert b2["page"] == 2
133+
assert len(b2["entries"]) == 5
134+
135+
136+
@pytest.mark.django_db
137+
def test_structured_message_surfaced(superuser_client, django_user_model) -> None:
138+
g = Group.objects.create(name="g")
139+
user = django_user_model.objects.filter(is_superuser=True).first()
140+
_log(g, user, message=json.dumps([{"changed": {"fields": ["name"]}}]))
141+
response = superuser_client.get(_history_url(g.pk))
142+
entry = response.json()["entries"][0]
143+
assert entry["change_message_structured"] == [{"changed": {"fields": ["name"]}}]
144+
assert "name" in entry["change_message_human"].lower()

0 commit comments

Comments
 (0)