diff --git a/src/woo_publications/logging/admin.py b/src/woo_publications/logging/admin.py index af368346..f7f92866 100644 --- a/src/woo_publications/logging/admin.py +++ b/src/woo_publications/logging/admin.py @@ -1,5 +1,91 @@ from django.contrib import admin +from django.http import HttpRequest +from django.urls import reverse +from django.utils.html import format_html, strip_tags +from django.utils.text import Truncator +from django.utils.translation import gettext, gettext_lazy as _ from timeline_logger.models import TimelineLog +from .constants import Events +from .models import TimelineLogProxy + admin.site.unregister(TimelineLog) + + +@admin.register(TimelineLogProxy) +class TimelineLogProxyAdmin(admin.ModelAdmin): + list_display = ( + "message", + "show_event", + "timestamp", + "show_acting_user", + "content_admin_link", + ) + # TODO: add filters/search on event and acting user + list_filter = ("timestamp",) + ordering = ("-timestamp",) + search_fields = ( + "extra_data__acting_user__identifier", + "extra_data__acting_user__display_name", + ) + list_select_related = ("content_type", "user") + date_hierarchy = "timestamp" + readonly_fields = ("get_message",) + + def has_add_permission(self, request: HttpRequest): + # Not even superusers are allowed to make changes + return False + + def has_change_permission( + self, request: HttpRequest, obj: TimelineLogProxy | None = None + ): + # Not even superusers are allowed to make changes + return False + + def has_delete_permission( + self, request: HttpRequest, obj: TimelineLogProxy | None = None + ): + # Not even superusers are allowed to make changes + return False + + @admin.display(description=_("full log message")) + def full_message(self, obj: TimelineLogProxy) -> str: + return obj.get_message() + + @admin.display(description=_("message")) + def message(self, obj: TimelineLogProxy) -> str: + full_msg = strip_tags(self.full_message(obj)) + return Truncator(full_msg).chars(100) or gettext("(no message)") + + @admin.display(description=_("event"), ordering="extra_data__event") + def show_event(self, obj: TimelineLogProxy) -> str: + if (event := obj.event) == "unknown": + return gettext("Unknown") + return Events(event).label + + @admin.display(description=_("acting user")) + def show_acting_user(self, obj: TimelineLogProxy) -> str: + acting_user, django_user = obj.acting_user + format_str = ( + gettext("{name} ({identifier})") + if not django_user + else gettext("{name} ({identifier}, local user ID: {django_id})") + ) + return format_str.format( + name=acting_user["display_name"], + identifier=acting_user["identifier"], + django_id=django_user.pk if django_user else None, + ) + + @admin.display(description=_("affected object")) + def content_admin_link(self, obj: TimelineLogProxy) -> str: + if not (obj.object_id and obj.content_type_id): + return "-" + ct = obj.content_type + admin_path = reverse( + f"admin:{ct.app_label}_{ct.model}_change", args=(obj.object_id,) + ) + return format_html( + '{t}', u=admin_path, t=str(obj.content_object) + ) diff --git a/src/woo_publications/logging/constants.py b/src/woo_publications/logging/constants.py new file mode 100644 index 00000000..a8610f63 --- /dev/null +++ b/src/woo_publications/logging/constants.py @@ -0,0 +1,12 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class Events(models.TextChoices): + # generic events mapping to standard CRUD operations + create = "create", _("Record created") + read = "read", _("Record read") + update = "update", _("Record updated") + delete = "delete", _("Record deleted") + # Specific events + ... diff --git a/src/woo_publications/logging/migrations/0001_initial.py b/src/woo_publications/logging/migrations/0001_initial.py new file mode 100644 index 00000000..a6c840eb --- /dev/null +++ b/src/woo_publications/logging/migrations/0001_initial.py @@ -0,0 +1,27 @@ +# Generated by Django 4.2.16 on 2024-10-09 15:13 + +from django.db import migrations + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("timeline_logger", "0006_auto_20220413_0749"), + ] + + operations = [ + migrations.CreateModel( + name="TimelineLogProxy", + fields=[], + options={ + "verbose_name": "(audit) log entry", + "verbose_name_plural": "(audit) log entries", + "proxy": True, + "indexes": [], + "constraints": [], + }, + bases=("timeline_logger.timelinelog",), + ), + ] diff --git a/src/woo_publications/logging/migrations/__init__.py b/src/woo_publications/logging/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/woo_publications/logging/models.py b/src/woo_publications/logging/models.py new file mode 100644 index 00000000..8e19e6dc --- /dev/null +++ b/src/woo_publications/logging/models.py @@ -0,0 +1,117 @@ +from typing import Literal, TypedDict + +from django.utils.translation import gettext_lazy as _ + +from timeline_logger.models import TimelineLog + +from woo_publications.accounts.models import User + +from .constants import Events + + +class ActingUser(TypedDict): + identifier: str + display_name: str + + +class MetadataDict(TypedDict): + """ + Optimistic model for the metadata - unfortunately we can't add DB constraints. + """ + + event: str + acting_user: ActingUser + + +class TimelineLogProxy(TimelineLog): + """ + Proxy the Python API of the package model. + + django-timeline-logger is agnostic, so we provide a convenience wrapper via this + proxy model. + """ + + content_type_id: int | None + + extra_data: MetadataDict + + class Meta: # type: ignore + proxy = True + verbose_name = _("(audit) log entry") + verbose_name_plural = _("(audit) log entries") + + def save(self, *args, **kwargs): + # there's a setting for this, but then makemigrations produces a new migration + # in the third party package which is less than ideal... + if self.template == "timeline_logger/default.txt": + self.template = "logging/message.txt" + + if self.extra_data is None: + raise ValueError("'extra_data' may not be empty.") + if not isinstance(self.extra_data, dict): + raise TypeError("'extra_data' must be a JSON object (python dict)") + + # ensure that we always track the event + self._validate_event() + # ensure that we always track the acting user + self._validate_user_details() + + super().save(*args, **kwargs) + + def _validate_event(self): + try: + Events(self.extra_data["event"]) + except (ValueError, KeyError) as enum_error: + raise ValueError( + "The extra data must contain an 'event' key from the " + "'woo_publications.logging.constants.Events' enum." + ) from enum_error + + def _validate_user_details(self): + """ + Validate that the extra metadata contains snapshot data of the acting user. + + Note that we track a FK to the django user model too, but we also store audit + log events of users that don't exist in our local database. Additionally, if + a user is deleted, we want to retain the audit logs of them. + """ + try: + user_details = self.extra_data["acting_user"] + except KeyError as err: + raise ValueError( + "Audit logs must contain the 'acting_user' key in the metadata" + ) from err + + try: + user_details["identifier"] + user_details["display_name"] + except KeyError as err: + raise ValueError( + "The user details in audit logs must contain the 'identifier' and " + "'display_name' keys." + ) from err + + @property + def event(self) -> Events | Literal["unknown"]: + """ + Extract the semantic event from the metadata. + + It's possible log records exist that have an 'event' value that was once defined + in code, but no longer is - in that case, the literal string "unknown" is + returned. + """ + try: + return Events(self.extra_data["event"]) + except ValueError: + return "unknown" + + @property + def acting_user(self) -> tuple[ActingUser, User | None]: + """ + Get information of the acting user. + + Returns a tuple that always contains the recorded acting user metadata as first + element. The second element is the Django user instance if it is known, + otherwise ``None``. + """ + return (self.extra_data["acting_user"], self.user) diff --git a/src/woo_publications/logging/templates/logging/message.txt b/src/woo_publications/logging/templates/logging/message.txt new file mode 100644 index 00000000..f197a505 --- /dev/null +++ b/src/woo_publications/logging/templates/logging/message.txt @@ -0,0 +1,3 @@ +{% load i18n %}{% blocktrans trimmed with timestamp=log.timestamp|date:'DATETIME_FORMAT' user=log.extra_data.acting_user.display_name|default:_('Anonymous user') object=log.content_object|default:_('(object not set)') event=log.extra_data.event %} +{{ timestamp }} | {{ user }} | {{ event }}: {{ object }}. +{% endblocktrans %}