Skip to content

Commit

Permalink
✨ [#16] Set up proxy model and admin integration for audit logs
Browse files Browse the repository at this point in the history
* Added proxy model, which enforces that an event and acting user is
  specified in the metadata of the log record. This is not 100% idiot
  proof since bulk update queries etc. and direct timeline-logger
  usage allows circumventing these. Ideally we'd have some database
  constraints, but those can't be specified through a proxy model.
* Added a custom admin integration for the proxy model:
  - block writing to log records entirely, even for superusers
  - process the event/acting_user structured data to display it as nice
    columns
  - allow searching on the user details in the admin log page
* Added a custom default message template that can leverage the
  metadata constraints that are enforced.
  • Loading branch information
sergei-maertens committed Oct 9, 2024
1 parent 32ba9ba commit 57efe37
Show file tree
Hide file tree
Showing 6 changed files with 245 additions and 0 deletions.
86 changes: 86 additions & 0 deletions src/woo_publications/logging/admin.py
Original file line number Diff line number Diff line change
@@ -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(
'<a href="{u}">{t}</a>', u=admin_path, t=str(obj.content_object)
)
12 changes: 12 additions & 0 deletions src/woo_publications/logging/constants.py
Original file line number Diff line number Diff line change
@@ -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
...
27 changes: 27 additions & 0 deletions src/woo_publications/logging/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -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",),
),
]
Empty file.
117 changes: 117 additions & 0 deletions src/woo_publications/logging/models.py
Original file line number Diff line number Diff line change
@@ -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)
3 changes: 3 additions & 0 deletions src/woo_publications/logging/templates/logging/message.txt
Original file line number Diff line number Diff line change
@@ -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 }} | <code>{{ event }}</code>: {{ object }}.
{% endblocktrans %}

0 comments on commit 57efe37

Please sign in to comment.