diff --git a/website/admin/__init__.py b/website/admin/__init__.py
index f2ab80a6..5980c694 100644
--- a/website/admin/__init__.py
+++ b/website/admin/__init__.py
@@ -56,6 +56,7 @@
banner_admin,
grant_admin,
keyword_admin,
+ logentry_admin,
news_admin,
person_admin,
photo_admin,
diff --git a/website/admin/admin_site.py b/website/admin/admin_site.py
index 73c4e7e8..d3d0caa2 100644
--- a/website/admin/admin_site.py
+++ b/website/admin/admin_site.py
@@ -73,7 +73,7 @@ class MakeabilityLabAdminSite(admin.AdminSite):
),
(
"Administration",
- ["Group", "User"],
+ ["Group", "User", "LogEntry"],
None
),
]
diff --git a/website/admin/logentry_admin.py b/website/admin/logentry_admin.py
new file mode 100644
index 00000000..571b33b3
--- /dev/null
+++ b/website/admin/logentry_admin.py
@@ -0,0 +1,99 @@
+"""
+Read-only admin for Django's ``LogEntry`` audit log.
+
+Django records every add/change/delete performed through the admin in
+``django.contrib.admin.models.LogEntry``, but does not surface it anywhere in
+the UI—the "Recent actions" sidebar on the index page is deliberately scoped to
+the current user's own actions (``{% get_admin_log ... for_user user %}``).
+
+This registers ``LogEntry`` as a fully read-only changelist so a superuser can
+browse *everyone's* admin activity, filtered by user, action type, content
+type, and date. It is intentionally superuser-only (like Grant/Award) because
+it exposes who edited what across all accounts.
+
+The log is append-only by Django and this admin never adds, edits, or deletes
+rows; it is purely a viewer.
+"""
+
+from django.contrib import admin
+from django.contrib.admin.models import LogEntry, ADDITION, CHANGE, DELETION
+from django.utils.html import format_html
+
+from website.admin.admin_site import ml_admin_site
+
+
+@admin.register(LogEntry, site=ml_admin_site)
+class LogEntryAdmin(admin.ModelAdmin):
+ """Superuser-only, read-only browser over the admin action log."""
+
+ # Newest first—matches the mental model of an activity feed.
+ ordering = ('-action_time',)
+ date_hierarchy = 'action_time'
+
+ list_display = (
+ 'action_time',
+ 'user',
+ 'action_label',
+ 'content_type',
+ 'object_link',
+ 'change_summary',
+ )
+ list_filter = ('action_flag', 'content_type', 'user')
+ search_fields = ('object_repr', 'change_message', 'user__username',
+ 'user__first_name', 'user__last_name')
+
+ # FK columns rendered on every row—join them in the changelist query so we
+ # don't fire per-row lookups (#1346).
+ list_select_related = ('user', 'content_type')
+
+ @admin.display(description='Action', ordering='action_flag')
+ def action_label(self, obj):
+ """Human-readable action name with a color cue."""
+ label, color = {
+ ADDITION: ('Added', '#2e7d32'),
+ CHANGE: ('Changed', '#946c00'),
+ DELETION: ('Deleted', '#b00020'),
+ }.get(obj.action_flag, ('Unknown', '#666'))
+ return format_html('{}', color, label)
+
+ @admin.display(description='Object')
+ def object_link(self, obj):
+ """The affected object as a link to its admin edit page.
+
+ Deletions have no surviving object to link to (and the referenced row
+ may be gone), so we fall back to the recorded ``object_repr`` text.
+ """
+ if obj.action_flag != DELETION:
+ try:
+ url = obj.get_admin_url()
+ except Exception:
+ url = None
+ if url:
+ return format_html('{}', url, obj.object_repr)
+ return obj.object_repr or '—'
+
+ @admin.display(description='Details')
+ def change_summary(self, obj):
+ """Django's formatted change message (e.g. 'Changed Title and Authors')."""
+ message = obj.get_change_message()
+ return message or '—'
+
+ # --- Read-only + superuser-only enforcement -------------------------------
+ # LogEntry is an append-only audit trail; never allow mutation through here,
+ # and restrict all visibility to superusers (this exposes cross-account
+ # activity, like Grant/Award).
+
+ def has_add_permission(self, request):
+ return False
+
+ def has_change_permission(self, request, obj=None):
+ return False
+
+ def has_delete_permission(self, request, obj=None):
+ return False
+
+ def has_view_permission(self, request, obj=None):
+ return request.user.is_superuser
+
+ def has_module_permission(self, request):
+ return request.user.is_superuser
diff --git a/website/tests/test_logentry_admin.py b/website/tests/test_logentry_admin.py
new file mode 100644
index 00000000..9618f6e5
--- /dev/null
+++ b/website/tests/test_logentry_admin.py
@@ -0,0 +1,121 @@
+"""
+Regression tests for the read-only LogEntry admin (site-wide action log).
+
+Pins the two properties that matter: (1) the LogEntry changelist is a
+read-only viewer—no add/change/delete—and (2) it is superuser-only, so
+editors/contributors can neither see nor reach it. See website/admin/
+logentry_admin.py.
+"""
+
+from django.contrib.admin.models import LogEntry, ADDITION, CHANGE, DELETION
+from django.contrib.auth import get_user_model
+from django.contrib.contenttypes.models import ContentType
+from django.urls import reverse
+
+from website.admin.admin_site import ml_admin_site
+from website.admin.logentry_admin import LogEntryAdmin
+from website.tests.base import DatabaseTestCase
+
+User = get_user_model()
+
+
+class LogEntryAdminPermissionTests(DatabaseTestCase):
+ """The audit log is read-only and superuser-only."""
+
+ def setUp(self):
+ self.admin = LogEntryAdmin(LogEntry, ml_admin_site)
+ self.superuser = User.objects.create_superuser(
+ username="root", email="root@example.com", password="pw")
+ self.editor = User.objects.create_user(
+ username="editor", email="editor@example.com", password="pw",
+ is_staff=True)
+
+ def _request(self, user):
+ # Lightweight stand-in: the permission hooks only read request.user.
+ class _Req:
+ pass
+ req = _Req()
+ req.user = user
+ return req
+
+ def test_log_is_read_only(self):
+ req = self._request(self.superuser)
+ self.assertFalse(self.admin.has_add_permission(req))
+ self.assertFalse(self.admin.has_change_permission(req))
+ self.assertFalse(self.admin.has_delete_permission(req))
+
+ def test_only_superusers_can_view(self):
+ self.assertTrue(
+ self.admin.has_view_permission(self._request(self.superuser)))
+ self.assertTrue(
+ self.admin.has_module_permission(self._request(self.superuser)))
+ self.assertFalse(
+ self.admin.has_view_permission(self._request(self.editor)))
+ self.assertFalse(
+ self.admin.has_module_permission(self._request(self.editor)))
+
+
+class LogEntryAdminViewTests(DatabaseTestCase):
+ """End-to-end: the changelist renders for superusers and is blocked otherwise."""
+
+ def setUp(self):
+ self.superuser = User.objects.create_superuser(
+ username="root", email="root@example.com", password="pw")
+ self.editor = User.objects.create_user(
+ username="editor", email="editor@example.com", password="pw",
+ is_staff=True)
+ # Seed one log row of each action type so the display columns render.
+ ct = ContentType.objects.get_for_model(User)
+ for flag in (ADDITION, CHANGE, DELETION):
+ LogEntry.objects.log_action(
+ user_id=self.superuser.pk,
+ content_type_id=ct.pk,
+ object_id=self.editor.pk,
+ object_repr=str(self.editor),
+ action_flag=flag,
+ change_message="test",
+ )
+
+ def test_superuser_sees_changelist(self):
+ self.client.force_login(self.superuser)
+ url = reverse("admin:admin_logentry_changelist")
+ resp = self.client.get(url)
+ self.assertEqual(resp.status_code, 200)
+ # The seeded rows' object_repr should appear in the rendered list.
+ self.assertContains(resp, str(self.editor))
+
+ def test_non_superuser_is_denied(self):
+ self.client.force_login(self.editor)
+ url = reverse("admin:admin_logentry_changelist")
+ resp = self.client.get(url)
+ # Django admin redirects/403s when module perms are absent; either way
+ # the editor must not get a 200 list of everyone's actions.
+ self.assertNotEqual(resp.status_code, 200)
+
+
+class LogEntryAdminDisplayTests(DatabaseTestCase):
+ """The custom display columns don't raise on any action type."""
+
+ def setUp(self):
+ self.admin = LogEntryAdmin(LogEntry, ml_admin_site)
+ self.user = User.objects.create_superuser(
+ username="root", email="root@example.com", password="pw")
+ self.ct = ContentType.objects.get_for_model(User)
+
+ def _entry(self, flag):
+ return LogEntry.objects.log_action(
+ user_id=self.user.pk, content_type_id=self.ct.pk,
+ object_id=self.user.pk, object_repr=str(self.user),
+ action_flag=flag, change_message="changed something")
+
+ def test_deletion_object_link_falls_back_to_repr(self):
+ # Deletions have no live object to link to; must fall back to text,
+ # never raise (get_admin_url would point at a possibly-gone row).
+ entry = self._entry(DELETION)
+ self.assertIn(str(self.user), self.admin.object_link(entry))
+
+ def test_action_label_covers_all_flags(self):
+ for flag, expected in ((ADDITION, "Added"),
+ (CHANGE, "Changed"),
+ (DELETION, "Deleted")):
+ self.assertIn(expected, self.admin.action_label(self._entry(flag)))