Skip to content

Commit 1b799c8

Browse files
authored
Merge pull request #1414 from makeabilitylab/1413-superuser-admin-action-log
Superuser: browse site-wide admin action log (#1413)
2 parents e2291d2 + a195df3 commit 1b799c8

4 files changed

Lines changed: 222 additions & 1 deletion

File tree

website/admin/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
banner_admin,
5757
grant_admin,
5858
keyword_admin,
59+
logentry_admin,
5960
news_admin,
6061
person_admin,
6162
photo_admin,

website/admin/admin_site.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ class MakeabilityLabAdminSite(admin.AdminSite):
7373
),
7474
(
7575
"Administration",
76-
["Group", "User"],
76+
["Group", "User", "LogEntry"],
7777
None
7878
),
7979
]

website/admin/logentry_admin.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
"""
2+
Read-only admin for Django's ``LogEntry`` audit log.
3+
4+
Django records every add/change/delete performed through the admin in
5+
``django.contrib.admin.models.LogEntry``, but does not surface it anywhere in
6+
the UI—the "Recent actions" sidebar on the index page is deliberately scoped to
7+
the current user's own actions (``{% get_admin_log ... for_user user %}``).
8+
9+
This registers ``LogEntry`` as a fully read-only changelist so a superuser can
10+
browse *everyone's* admin activity, filtered by user, action type, content
11+
type, and date. It is intentionally superuser-only (like Grant/Award) because
12+
it exposes who edited what across all accounts.
13+
14+
The log is append-only by Django and this admin never adds, edits, or deletes
15+
rows; it is purely a viewer.
16+
"""
17+
18+
from django.contrib import admin
19+
from django.contrib.admin.models import LogEntry, ADDITION, CHANGE, DELETION
20+
from django.utils.html import format_html
21+
22+
from website.admin.admin_site import ml_admin_site
23+
24+
25+
@admin.register(LogEntry, site=ml_admin_site)
26+
class LogEntryAdmin(admin.ModelAdmin):
27+
"""Superuser-only, read-only browser over the admin action log."""
28+
29+
# Newest first—matches the mental model of an activity feed.
30+
ordering = ('-action_time',)
31+
date_hierarchy = 'action_time'
32+
33+
list_display = (
34+
'action_time',
35+
'user',
36+
'action_label',
37+
'content_type',
38+
'object_link',
39+
'change_summary',
40+
)
41+
list_filter = ('action_flag', 'content_type', 'user')
42+
search_fields = ('object_repr', 'change_message', 'user__username',
43+
'user__first_name', 'user__last_name')
44+
45+
# FK columns rendered on every row—join them in the changelist query so we
46+
# don't fire per-row lookups (#1346).
47+
list_select_related = ('user', 'content_type')
48+
49+
@admin.display(description='Action', ordering='action_flag')
50+
def action_label(self, obj):
51+
"""Human-readable action name with a color cue."""
52+
label, color = {
53+
ADDITION: ('Added', '#2e7d32'),
54+
CHANGE: ('Changed', '#946c00'),
55+
DELETION: ('Deleted', '#b00020'),
56+
}.get(obj.action_flag, ('Unknown', '#666'))
57+
return format_html('<span style="color: {};">{}</span>', color, label)
58+
59+
@admin.display(description='Object')
60+
def object_link(self, obj):
61+
"""The affected object as a link to its admin edit page.
62+
63+
Deletions have no surviving object to link to (and the referenced row
64+
may be gone), so we fall back to the recorded ``object_repr`` text.
65+
"""
66+
if obj.action_flag != DELETION:
67+
try:
68+
url = obj.get_admin_url()
69+
except Exception:
70+
url = None
71+
if url:
72+
return format_html('<a href="{}">{}</a>', url, obj.object_repr)
73+
return obj.object_repr or '—'
74+
75+
@admin.display(description='Details')
76+
def change_summary(self, obj):
77+
"""Django's formatted change message (e.g. 'Changed Title and Authors')."""
78+
message = obj.get_change_message()
79+
return message or '—'
80+
81+
# --- Read-only + superuser-only enforcement -------------------------------
82+
# LogEntry is an append-only audit trail; never allow mutation through here,
83+
# and restrict all visibility to superusers (this exposes cross-account
84+
# activity, like Grant/Award).
85+
86+
def has_add_permission(self, request):
87+
return False
88+
89+
def has_change_permission(self, request, obj=None):
90+
return False
91+
92+
def has_delete_permission(self, request, obj=None):
93+
return False
94+
95+
def has_view_permission(self, request, obj=None):
96+
return request.user.is_superuser
97+
98+
def has_module_permission(self, request):
99+
return request.user.is_superuser
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
"""
2+
Regression tests for the read-only LogEntry admin (site-wide action log).
3+
4+
Pins the two properties that matter: (1) the LogEntry changelist is a
5+
read-only viewer—no add/change/delete—and (2) it is superuser-only, so
6+
editors/contributors can neither see nor reach it. See website/admin/
7+
logentry_admin.py.
8+
"""
9+
10+
from django.contrib.admin.models import LogEntry, ADDITION, CHANGE, DELETION
11+
from django.contrib.auth import get_user_model
12+
from django.contrib.contenttypes.models import ContentType
13+
from django.urls import reverse
14+
15+
from website.admin.admin_site import ml_admin_site
16+
from website.admin.logentry_admin import LogEntryAdmin
17+
from website.tests.base import DatabaseTestCase
18+
19+
User = get_user_model()
20+
21+
22+
class LogEntryAdminPermissionTests(DatabaseTestCase):
23+
"""The audit log is read-only and superuser-only."""
24+
25+
def setUp(self):
26+
self.admin = LogEntryAdmin(LogEntry, ml_admin_site)
27+
self.superuser = User.objects.create_superuser(
28+
username="root", email="root@example.com", password="pw")
29+
self.editor = User.objects.create_user(
30+
username="editor", email="editor@example.com", password="pw",
31+
is_staff=True)
32+
33+
def _request(self, user):
34+
# Lightweight stand-in: the permission hooks only read request.user.
35+
class _Req:
36+
pass
37+
req = _Req()
38+
req.user = user
39+
return req
40+
41+
def test_log_is_read_only(self):
42+
req = self._request(self.superuser)
43+
self.assertFalse(self.admin.has_add_permission(req))
44+
self.assertFalse(self.admin.has_change_permission(req))
45+
self.assertFalse(self.admin.has_delete_permission(req))
46+
47+
def test_only_superusers_can_view(self):
48+
self.assertTrue(
49+
self.admin.has_view_permission(self._request(self.superuser)))
50+
self.assertTrue(
51+
self.admin.has_module_permission(self._request(self.superuser)))
52+
self.assertFalse(
53+
self.admin.has_view_permission(self._request(self.editor)))
54+
self.assertFalse(
55+
self.admin.has_module_permission(self._request(self.editor)))
56+
57+
58+
class LogEntryAdminViewTests(DatabaseTestCase):
59+
"""End-to-end: the changelist renders for superusers and is blocked otherwise."""
60+
61+
def setUp(self):
62+
self.superuser = User.objects.create_superuser(
63+
username="root", email="root@example.com", password="pw")
64+
self.editor = User.objects.create_user(
65+
username="editor", email="editor@example.com", password="pw",
66+
is_staff=True)
67+
# Seed one log row of each action type so the display columns render.
68+
ct = ContentType.objects.get_for_model(User)
69+
for flag in (ADDITION, CHANGE, DELETION):
70+
LogEntry.objects.log_action(
71+
user_id=self.superuser.pk,
72+
content_type_id=ct.pk,
73+
object_id=self.editor.pk,
74+
object_repr=str(self.editor),
75+
action_flag=flag,
76+
change_message="test",
77+
)
78+
79+
def test_superuser_sees_changelist(self):
80+
self.client.force_login(self.superuser)
81+
url = reverse("admin:admin_logentry_changelist")
82+
resp = self.client.get(url)
83+
self.assertEqual(resp.status_code, 200)
84+
# The seeded rows' object_repr should appear in the rendered list.
85+
self.assertContains(resp, str(self.editor))
86+
87+
def test_non_superuser_is_denied(self):
88+
self.client.force_login(self.editor)
89+
url = reverse("admin:admin_logentry_changelist")
90+
resp = self.client.get(url)
91+
# Django admin redirects/403s when module perms are absent; either way
92+
# the editor must not get a 200 list of everyone's actions.
93+
self.assertNotEqual(resp.status_code, 200)
94+
95+
96+
class LogEntryAdminDisplayTests(DatabaseTestCase):
97+
"""The custom display columns don't raise on any action type."""
98+
99+
def setUp(self):
100+
self.admin = LogEntryAdmin(LogEntry, ml_admin_site)
101+
self.user = User.objects.create_superuser(
102+
username="root", email="root@example.com", password="pw")
103+
self.ct = ContentType.objects.get_for_model(User)
104+
105+
def _entry(self, flag):
106+
return LogEntry.objects.log_action(
107+
user_id=self.user.pk, content_type_id=self.ct.pk,
108+
object_id=self.user.pk, object_repr=str(self.user),
109+
action_flag=flag, change_message="changed something")
110+
111+
def test_deletion_object_link_falls_back_to_repr(self):
112+
# Deletions have no live object to link to; must fall back to text,
113+
# never raise (get_admin_url would point at a possibly-gone row).
114+
entry = self._entry(DELETION)
115+
self.assertIn(str(self.user), self.admin.object_link(entry))
116+
117+
def test_action_label_covers_all_flags(self):
118+
for flag, expected in ((ADDITION, "Added"),
119+
(CHANGE, "Changed"),
120+
(DELETION, "Deleted")):
121+
self.assertIn(expected, self.admin.action_label(self._entry(flag)))

0 commit comments

Comments
 (0)