-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
✨ [#16] Set up proxy model and admin integration for audit logs
* 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
1 parent
32ba9ba
commit 57efe37
Showing
6 changed files
with
245 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
... |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 %} |