Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions website/admin/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
banner_admin,
grant_admin,
keyword_admin,
logentry_admin,
news_admin,
person_admin,
photo_admin,
Expand Down
2 changes: 1 addition & 1 deletion website/admin/admin_site.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ class MakeabilityLabAdminSite(admin.AdminSite):
),
(
"Administration",
["Group", "User"],
["Group", "User", "LogEntry"],
None
),
]
Expand Down
99 changes: 99 additions & 0 deletions website/admin/logentry_admin.py
Original file line number Diff line number Diff line change
@@ -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('<span style="color: {};">{}</span>', 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('<a href="{}">{}</a>', 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
121 changes: 121 additions & 0 deletions website/tests/test_logentry_admin.py
Original file line number Diff line number Diff line change
@@ -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)))
Loading