{% trans 'No log entries found.' %}
+ {% endif %} +diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bcb22f..5ba50d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Next Release +#### Improvements + +- feat: Add audit log history view to Django Admin. ([#743](https://github.com/jazzband/django-auditlog/pull/743)) + ## 3.2.1 (2025-07-03) #### Improvements diff --git a/auditlog/mixins.py b/auditlog/mixins.py index aa1ab51..b0cdc45 100644 --- a/auditlog/mixins.py +++ b/auditlog/mixins.py @@ -1,17 +1,21 @@ +from urllib.parse import unquote + from django import urls as urlresolvers from django.conf import settings from django.contrib import admin -from django.core.exceptions import FieldDoesNotExist -from django.forms.utils import pretty_name +from django.contrib.admin.views.main import PAGE_VAR +from django.core.exceptions import PermissionDenied from django.http import HttpRequest +from django.template.response import TemplateResponse +from django.urls import path, reverse from django.urls.exceptions import NoReverseMatch -from django.utils.html import format_html, format_html_join -from django.utils.safestring import mark_safe +from django.utils.html import format_html +from django.utils.text import capfirst from django.utils.timezone import is_aware, localtime from django.utils.translation import gettext_lazy as _ from auditlog.models import LogEntry -from auditlog.registry import auditlog +from auditlog.render import get_field_verbose_name, render_logentry_changes_html from auditlog.signals import accessed MAX = 75 @@ -68,55 +72,7 @@ class LogEntryAdminMixin: @admin.display(description=_("Changes")) def msg(self, obj): - changes = obj.changes_dict - - atom_changes = {} - m2m_changes = {} - - for field, change in changes.items(): - if isinstance(change, dict): - assert ( - change["type"] == "m2m" - ), "Only m2m operations are expected to produce dict changes now" - m2m_changes[field] = change - else: - atom_changes[field] = change - - msg = [] - - if atom_changes: - msg.append("
| {} | {} | {} | {} |
{% trans 'No log entries found.' %}
+ {% endif %} ++ {{ page_obj.paginator.count }} {% blocktranslate count counter=page_obj.paginator.count %}entry{% plural %}entries{% endblocktranslate %} +
diff --git a/auditlog/templatetags/__init__.py b/auditlog/templatetags/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/auditlog/templatetags/auditlog_tags.py b/auditlog/templatetags/auditlog_tags.py new file mode 100644 index 0000000..687f0f6 --- /dev/null +++ b/auditlog/templatetags/auditlog_tags.py @@ -0,0 +1,16 @@ +from django import template + +from auditlog.render import render_logentry_changes_html as render_changes + +register = template.Library() + + +@register.filter +def render_logentry_changes_html(log_entry): + """ + Format LogEntry changes as HTML. + + Usage in template: + {{ log_entry_object|render_logentry_changes_html|safe }} + """ + return render_changes(log_entry) diff --git a/auditlog_tests/test_render.py b/auditlog_tests/test_render.py new file mode 100644 index 0000000..a1063b6 --- /dev/null +++ b/auditlog_tests/test_render.py @@ -0,0 +1,165 @@ +from django.test import TestCase +from test_app.models import SimpleModel + +from auditlog.models import LogEntry +from auditlog.render import render_logentry_changes_html + + +class RenderChangesTest(TestCase): + + def _create_log_entry(self, action, changes): + return LogEntry.objects.log_create( + SimpleModel.objects.create(), + action=action, + changes=changes, + ) + + def test_render_changes_empty(self): + log_entry = self._create_log_entry(LogEntry.Action.CREATE, {}) + result = render_logentry_changes_html(log_entry) + self.assertEqual(result, "") + + def test_render_changes_simple_field(self): + changes = {"text": ["old text", "new text"]} + log_entry = self._create_log_entry(LogEntry.Action.UPDATE, changes) + + result = render_logentry_changes_html(log_entry) + + self.assertIn("| # | ", result) + self.assertIn("Field | ", result) + self.assertIn("From | ", result) + self.assertIn("To | ", result) + self.assertIn("old text", result) + self.assertIn("new text", result) + self.assertIsInstance(result, str) + + def test_render_changes_password_field(self): + changes = {"password": ["oldpass", "newpass"]} + log_entry = self._create_log_entry(LogEntry.Action.UPDATE, changes) + + result = render_logentry_changes_html(log_entry) + + self.assertIn("***", result) + self.assertNotIn("oldpass", result) + self.assertNotIn("newpass", result) + + def test_render_changes_m2m_field(self): + changes = { + "related_objects": { + "type": "m2m", + "operation": "add", + "objects": ["obj1", "obj2", "obj3"], + } + } + log_entry = self._create_log_entry(LogEntry.Action.UPDATE, changes) + + result = render_logentry_changes_html(log_entry) + + self.assertIn("
|---|
| # | ", result) + self.assertIn("Relationship | ", result) + self.assertIn("Action | ", result) + self.assertIn("Objects | ", result) + self.assertIn("add", result) + self.assertIn("obj1", result) + self.assertIn("obj2", result) + self.assertIn("obj3", result) + + def test_render_changes_mixed_fields(self): + changes = { + "text": ["old text", "new text"], + "related_objects": { + "type": "m2m", + "operation": "remove", + "objects": ["obj1"], + }, + } + log_entry = self._create_log_entry(LogEntry.Action.UPDATE, changes) + + result = render_logentry_changes_html(log_entry) + + tables = result.count("
|---|