-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathrecent_actions.py
More file actions
139 lines (117 loc) · 5.13 KB
/
Copy pathrecent_actions.py
File metadata and controls
139 lines (117 loc) · 5.13 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
"""``GET /api/v1/recent-actions/`` — the current user's recent actions.
Mirrors Django admin's index "Recent actions" panel: the signed-in
user's own last few ``LogEntry`` rows, newest first, each linking to
the affected object's change page when that object is still reachable.
Like the rest of the API this is staff-gated and ``Cache-Control:
no-store``. It is scoped to ``request.user`` — a user only ever sees
their own action log, never anyone else's (``SECURITY.md`` §3 rule 12).
The link target is gated by the target model's admin registration +
view permission, so the panel never links into a 403/404.
"""
from __future__ import annotations
from typing import Any
from typing import cast
from django.contrib.admin.models import ADDITION
from django.contrib.admin.models import CHANGE
from django.contrib.admin.models import DELETION
from django.contrib.admin.models import LogEntry
from django.contrib.contenttypes.models import ContentType
from django.http import HttpRequest
from django.http import HttpResponse
from django.http import JsonResponse
from django.views.generic import View
from django_admin_react.api.permissions import forbidden_response
from django_admin_react.api.permissions import is_admin_user
from django_admin_react.api.registry import get_admin_site
from django_admin_react.audit import recent_actions_for_user
# Default / ceiling for the number of entries returned. Django's index
# shows 10; the ceiling keeps a hand-crafted ``?limit=`` from scanning
# the whole table.
_DEFAULT_LIMIT = 10
_MAX_LIMIT = 100
_ACTION_LABELS: dict[int, str] = {
ADDITION: "added",
CHANGE: "changed",
DELETION: "deleted",
}
class RecentActionsView(View):
"""Return the signed-in user's recent admin actions (index parity)."""
http_method_names = ["get"]
def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
"""The current user's last ``limit`` ``LogEntry`` rows.
Gate: ``is_admin_user`` (staff + ``AdminSite.has_permission``).
Scoped to ``request.user`` — no cross-user leakage.
"""
admin_site = get_admin_site()
if not is_admin_user(request, admin_site=admin_site):
return forbidden_response(request)
# ``is_admin_user`` guarantees an authenticated user, so pk is set
# (it may be int or str for a custom user model — both are valid
# lookups). Scoped to this user only; the LogEntry query lives in
# ``audit.py`` (outside ``api/``), the designated home for the
# framework audit table — see that module's docstring.
user_pk = cast("str | int", request.user.pk)
entries = list(recent_actions_for_user(user_pk, _limit(request)))
body = {"actions": [_serialize_action(e, admin_site, request) for e in entries]}
response = JsonResponse(body, status=200)
response["Cache-Control"] = "no-store"
return response
def _serialize_action(entry: LogEntry, admin_site: Any, request: HttpRequest) -> dict[str, Any]:
"""One ``LogEntry`` → recent-actions wire shape.
``object_repr`` is what the user saw when they performed the action
(Django stores it on the row), so it is safe to echo back even for a
now-deleted object. ``target`` is the navigable change-page locator,
or ``null`` when the object can't be linked (deleted, unregistered
model, or no view permission) — the SPA then renders plain text.
"""
return {
"id": entry.id,
"action": _ACTION_LABELS.get(entry.action_flag, "unknown"),
"action_time": entry.action_time.isoformat(),
"object_repr": entry.object_repr,
"target": _target_for(entry, admin_site, request),
}
def _target_for(
entry: LogEntry, admin_site: Any, request: HttpRequest
) -> dict[str, Any] | None:
"""Change-page locator for the entry's object, or ``None``.
Returns ``None`` for deletions (no live object), for content types
whose model can't be resolved, for models not registered on the
admin site, and when the user lacks module / view permission — so
the SPA never offers a link that would 403 or 404.
"""
if entry.action_flag == DELETION:
return None
ct_id = entry.content_type_id
if ct_id is None:
return None
try:
content_type = ContentType.objects.get_for_id(ct_id)
except ContentType.DoesNotExist:
return None
model = content_type.model_class()
if model is None:
return None
model_admin = admin_site._registry.get(model)
if model_admin is None:
return None
if not model_admin.has_module_permission(request):
return None
if not model_admin.has_view_permission(request):
return None
meta = model._meta
return {
"app_label": meta.app_label,
"model_name": meta.model_name,
"pk": entry.object_id,
}
def _limit(request: HttpRequest) -> int:
"""Clamp the ``limit`` query param to ``[1, _MAX_LIMIT]``."""
raw = request.GET.get("limit")
if raw is None:
return _DEFAULT_LIMIT
try:
value = int(raw)
except (TypeError, ValueError):
return _DEFAULT_LIMIT
return max(1, min(value, _MAX_LIMIT))