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 %}