From 9ef8cf24763598eea925ae24f6217c0fd8c87a7e Mon Sep 17 00:00:00 2001 From: Youngkwang Yang Date: Tue, 5 Aug 2025 20:02:43 +0900 Subject: [PATCH] Add audit log history view to Django Admin (#743) * split auditlog HTML render logic from Admin mixin into reusable functions * add AuditlogHistoryAdminMixin class * add test cases for auditlog html render functions * add audit log history view documentation * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Fix minor * Add missing versionadded and configuration options for AuditlogHistoryAdminMixin * Add missing test cases * Update versionadded to 3.2.2 for AuditlogHistoryAdminMixin * Update CHANGELOG.md --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- CHANGELOG.md | 4 + auditlog/mixins.py | 172 +++++++++-------- auditlog/render.py | 94 ++++++++++ auditlog/templates/auditlog/entry_detail.html | 18 ++ .../templates/auditlog/object_history.html | 160 ++++++++++++++++ auditlog/templates/auditlog/pagination.html | 16 ++ auditlog/templatetags/__init__.py | 0 auditlog/templatetags/auditlog_tags.py | 16 ++ auditlog_tests/test_render.py | 165 +++++++++++++++++ auditlog_tests/test_view.py | 175 ++++++++++++++++++ docs/source/usage.rst | 23 +++ 11 files changed, 761 insertions(+), 82 deletions(-) create mode 100644 auditlog/render.py create mode 100644 auditlog/templates/auditlog/entry_detail.html create mode 100644 auditlog/templates/auditlog/object_history.html create mode 100644 auditlog/templates/auditlog/pagination.html create mode 100644 auditlog/templatetags/__init__.py create mode 100644 auditlog/templatetags/auditlog_tags.py create mode 100644 auditlog_tests/test_render.py create mode 100644 auditlog_tests/test_view.py 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("") - msg.append(self._format_header("#", "Field", "From", "To")) - for i, (field, change) in enumerate(sorted(atom_changes.items()), 1): - value = [i, self.field_verbose_name(obj, field)] + ( - ["***", "***"] if field == "password" else change - ) - msg.append(self._format_line(*value)) - msg.append("
") - - if m2m_changes: - msg.append("") - msg.append(self._format_header("#", "Relationship", "Action", "Objects")) - for i, (field, change) in enumerate(sorted(m2m_changes.items()), 1): - change_html = format_html_join( - mark_safe("
"), - "{}", - [(value,) for value in change["objects"]], - ) - - msg.append( - format_html( - "", - i, - self.field_verbose_name(obj, field), - change["operation"], - change_html, - ) - ) - - msg.append("
{}{}{}{}
") - - return mark_safe("".join(msg)) + return render_logentry_changes_html(obj) @admin.display(description="Correlation ID") def cid_url(self, obj): @@ -127,43 +83,95 @@ class LogEntryAdminMixin: '{}', url, self.CID_TITLE, cid ) - def _format_header(self, *labels): - return format_html( - "".join(["", "{}" * len(labels), ""]), *labels - ) - - def _format_line(self, *values): - return format_html( - "".join(["", "{}" * len(values), ""]), *values - ) - - def field_verbose_name(self, obj, field_name: str): - model = obj.content_type.model_class() - if model is None: - return field_name - try: - model_fields = auditlog.get_model_fields(model._meta.model) - mapping_field_name = model_fields["mapping_fields"].get(field_name) - if mapping_field_name: - return mapping_field_name - except KeyError: - # Model definition in auditlog was probably removed - pass - try: - field = model._meta.get_field(field_name) - return pretty_name(getattr(field, "verbose_name", field_name)) - except FieldDoesNotExist: - return pretty_name(field_name) - def _add_query_parameter(self, key: str, value: str): full_path = self.request.get_full_path() delimiter = "&" if "?" in full_path else "?" return f"{full_path}{delimiter}{key}={value}" + def field_verbose_name(self, obj, field_name: str): + """ + Use `auditlog.render.get_field_verbose_name` instead. + This method is kept for backward compatibility. + """ + return get_field_verbose_name(obj, field_name) + class LogAccessMixin: def render_to_response(self, context, **response_kwargs): obj = self.get_object() accessed.send(obj.__class__, instance=obj) return super().render_to_response(context, **response_kwargs) + + +class AuditlogHistoryAdminMixin: + """ + Add an audit log history view to a model admin. + """ + + auditlog_history_template = "auditlog/object_history.html" + show_auditlog_history_link = False + auditlog_history_per_page = 10 + + def get_list_display(self, request): + list_display = list(super().get_list_display(request)) + if self.show_auditlog_history_link and "auditlog_link" not in list_display: + list_display.append("auditlog_link") + + return list_display + + def get_urls(self): + opts = self.model._meta + info = opts.app_label, opts.model_name + my_urls = [ + path( + "/auditlog/", + self.admin_site.admin_view(self.auditlog_history_view), + name="%s_%s_auditlog" % info, + ) + ] + + return my_urls + super().get_urls() + + def auditlog_history_view(self, request, object_id, extra_context=None): + obj = self.get_object(request, unquote(object_id)) + if not self.has_view_permission(request, obj): + raise PermissionDenied + + log_entries = ( + LogEntry.objects.get_for_object(obj) + .select_related("actor") + .order_by("-timestamp") + ) + + paginator = self.get_paginator( + request, log_entries, self.auditlog_history_per_page + ) + page_number = request.GET.get(PAGE_VAR, 1) + page_obj = paginator.get_page(page_number) + page_range = paginator.get_elided_page_range(page_obj.number) + + context = { + **self.admin_site.each_context(request), + "title": _("Audit log: %s") % obj, + "module_name": str(capfirst(self.model._meta.verbose_name_plural)), + "page_range": page_range, + "page_var": PAGE_VAR, + "pagination_required": paginator.count > self.auditlog_history_per_page, + "object": obj, + "opts": self.model._meta, + "log_entries": page_obj, + **(extra_context or {}), + } + + return TemplateResponse(request, self.auditlog_history_template, context) + + @admin.display(description=_("Audit log")) + def auditlog_link(self, obj): + opts = self.model._meta + url = reverse( + f"admin:{opts.app_label}_{opts.model_name}_auditlog", + args=[obj.pk], + ) + + return format_html('{}', url, _("View")) diff --git a/auditlog/render.py b/auditlog/render.py new file mode 100644 index 0000000..6c9c3ee --- /dev/null +++ b/auditlog/render.py @@ -0,0 +1,94 @@ +from django.core.exceptions import FieldDoesNotExist +from django.forms.utils import pretty_name +from django.utils.html import format_html, format_html_join +from django.utils.safestring import mark_safe + + +def render_logentry_changes_html(log_entry): + changes = log_entry.changes_dict + if not changes: + return "" + + atom_changes = {} + m2m_changes = {} + + # Separate regular fields from M2M changes + for field, change in changes.items(): + if isinstance(change, dict) and change.get("type") == "m2m": + m2m_changes[field] = change + else: + atom_changes[field] = change + + html_parts = [] + + # Render regular field changes + if atom_changes: + html_parts.append(_render_field_changes(log_entry, atom_changes)) + + # Render M2M relationship changes + if m2m_changes: + html_parts.append(_render_m2m_changes(log_entry, m2m_changes)) + + return mark_safe("".join(html_parts)) + + +def get_field_verbose_name(log_entry, field_name): + from auditlog.registry import auditlog + + model = log_entry.content_type.model_class() + if model is None: + return field_name + + # Try to get verbose name from auditlog mapping + try: + if auditlog.contains(model._meta.model): + model_fields = auditlog.get_model_fields(model._meta.model) + mapping_field_name = model_fields["mapping_fields"].get(field_name) + if mapping_field_name: + return mapping_field_name + except KeyError: + # Model definition in auditlog was probably removed + pass + + # Fall back to Django field verbose_name + try: + field = model._meta.get_field(field_name) + return pretty_name(getattr(field, "verbose_name", field_name)) + except FieldDoesNotExist: + return pretty_name(field_name) + + +def _render_field_changes(log_entry, atom_changes): + rows = [] + rows.append(_format_header("#", "Field", "From", "To")) + + for i, (field, change) in enumerate(sorted(atom_changes.items()), 1): + field_name = get_field_verbose_name(log_entry, field) + values = ["***", "***"] if field == "password" else change + rows.append(_format_row(i, field_name, *values)) + + return f"{''.join(rows)}
" + + +def _render_m2m_changes(log_entry, m2m_changes): + rows = [] + rows.append(_format_header("#", "Relationship", "Action", "Objects")) + + for i, (field, change) in enumerate(sorted(m2m_changes.items()), 1): + field_name = get_field_verbose_name(log_entry, field) + objects_html = format_html_join( + mark_safe("
"), + "{}", + [(obj,) for obj in change["objects"]], + ) + rows.append(_format_row(i, field_name, change["operation"], objects_html)) + + return f"{''.join(rows)}
" + + +def _format_header(*labels): + return format_html("".join(["", "{}" * len(labels), ""]), *labels) + + +def _format_row(*values): + return format_html("".join(["", "{}" * len(values), ""]), *values) diff --git a/auditlog/templates/auditlog/entry_detail.html b/auditlog/templates/auditlog/entry_detail.html new file mode 100644 index 0000000..9cd45fa --- /dev/null +++ b/auditlog/templates/auditlog/entry_detail.html @@ -0,0 +1,18 @@ +{% load i18n auditlog_tags %} + +
+
+ +
+
+ {% if entry.action == entry.Action.DELETE or entry.action == entry.Action.ACCESS %} + {% trans 'No field changes' %} + {% else %} + {{ entry|render_logentry_changes_html|safe }} + {% endif %} +
+
diff --git a/auditlog/templates/auditlog/object_history.html b/auditlog/templates/auditlog/object_history.html new file mode 100644 index 0000000..d20cd35 --- /dev/null +++ b/auditlog/templates/auditlog/object_history.html @@ -0,0 +1,160 @@ +{% extends "admin/base_site.html" %} +{% load i18n admin_urls static %} + +{% block extrahead %} + {{ block.super }} + +{% endblock %} + +{% block breadcrumbs %} + +{% endblock %} + +{% block content %} +
+
+ {% if log_entries %} +
+ {% for entry in log_entries %} + {% include "auditlog/entry_detail.html" with entry=entry %} + {% endfor %} +
+ + {% if pagination_required %} + {% include "auditlog/pagination.html" with page_obj=log_entries page_var=page_var %} + {% endif %} + {% else %} +

{% trans 'No log entries found.' %}

+ {% endif %} +
+
+{% endblock %} diff --git a/auditlog/templates/auditlog/pagination.html b/auditlog/templates/auditlog/pagination.html new file mode 100644 index 0000000..cd08216 --- /dev/null +++ b/auditlog/templates/auditlog/pagination.html @@ -0,0 +1,16 @@ +{% load i18n %} + + +

+ {{ 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("", result) + self.assertIn("", result) + self.assertIn("", result) + self.assertIn("", 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("
#FieldFromTo
", result) + self.assertIn("", result) + self.assertIn("", result) + self.assertIn("", result) + self.assertIn("", 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("
#RelationshipActionObjects
") + self.assertEqual(tables, 2) + + self.assertIn("old text", result) + self.assertIn("new text", result) + + self.assertIn("remove", result) + self.assertIn("obj1", result) + + def test_render_changes_field_verbose_name(self): + changes = {"text": ["old", "new"]} + log_entry = self._create_log_entry(LogEntry.Action.UPDATE, changes) + + result = render_logentry_changes_html(log_entry) + + self.assertIn("Text", result) + + def test_render_changes_with_none_values(self): + changes = {"text": [None, "new text"], "boolean": [True, None]} + log_entry = self._create_log_entry(LogEntry.Action.UPDATE, changes) + + result = render_logentry_changes_html(log_entry) + + self.assertIn("None", result) + self.assertIn("new text", result) + self.assertIn("True", result) + + def test_render_changes_sorted_fields(self): + changes = { + "z_field": ["old", "new"], + "a_field": ["old", "new"], + "m_field": ["old", "new"], + } + log_entry = self._create_log_entry(LogEntry.Action.UPDATE, changes) + + result = render_logentry_changes_html(log_entry) + + a_index = result.find("A field") + m_index = result.find("M field") + z_index = result.find("Z field") + + self.assertLess(a_index, m_index) + self.assertLess(m_index, z_index) + + def test_render_changes_m2m_sorted_fields(self): + changes = { + "z_related": {"type": "m2m", "operation": "add", "objects": ["obj1"]}, + "a_related": {"type": "m2m", "operation": "remove", "objects": ["obj2"]}, + } + log_entry = self._create_log_entry(LogEntry.Action.UPDATE, changes) + + result = render_logentry_changes_html(log_entry) + + a_index = result.find("A related") + z_index = result.find("Z related") + + self.assertLess(a_index, z_index) + + def test_render_changes_create_action(self): + changes = { + "text": [None, "new value"], + "boolean": [None, True], + } + log_entry = self._create_log_entry(LogEntry.Action.CREATE, changes) + + result = render_logentry_changes_html(log_entry) + + self.assertIn("
", result) + self.assertIn("new value", result) + self.assertIn("True", result) + + def test_render_changes_delete_action(self): + changes = { + "text": ["old value", None], + "boolean": [True, None], + } + log_entry = self._create_log_entry(LogEntry.Action.DELETE, changes) + + result = render_logentry_changes_html(log_entry) + + self.assertIn("
", result) + self.assertIn("old value", result) + self.assertIn("True", result) + self.assertIn("None", result) diff --git a/auditlog_tests/test_view.py b/auditlog_tests/test_view.py new file mode 100644 index 0000000..32d1930 --- /dev/null +++ b/auditlog_tests/test_view.py @@ -0,0 +1,175 @@ +from unittest.mock import patch + +from django.contrib import admin +from django.contrib.admin.sites import AdminSite +from django.contrib.auth import get_user_model +from django.test import RequestFactory, TestCase +from test_app.models import SimpleModel + +from auditlog.mixins import AuditlogHistoryAdminMixin + + +class TestModelAdmin(AuditlogHistoryAdminMixin, admin.ModelAdmin): + model = SimpleModel + auditlog_history_per_page = 5 + + +class TestAuditlogHistoryAdminMixin(TestCase): + def setUp(self): + self.user = get_user_model().objects.create_user( + username="test_admin", is_staff=True, is_superuser=True, is_active=True + ) + self.site = AdminSite() + + self.admin = TestModelAdmin(SimpleModel, self.site) + + self.obj = SimpleModel.objects.create(text="Test object") + + def test_auditlog_history_view_requires_permission(self): + request = RequestFactory().get("/") + request.user = get_user_model().objects.create_user( + username="non_staff_user", password="testpass" + ) + + with self.assertRaises(Exception): + self.admin.auditlog_history_view(request, str(self.obj.pk)) + + def test_auditlog_history_view_with_permission(self): + request = RequestFactory().get("/") + request.user = self.user + + response = self.admin.auditlog_history_view(request, str(self.obj.pk)) + + self.assertEqual(response.status_code, 200) + self.assertIn("log_entries", response.context_data) + self.assertIn("object", response.context_data) + self.assertEqual(response.context_data["object"], self.obj) + + def test_auditlog_history_view_pagination(self): + """Test that pagination works correctly.""" + for i in range(10): + self.obj.text = f"Updated text {i}" + self.obj.save() + + request = RequestFactory().get("/") + request.user = self.user + + response = self.admin.auditlog_history_view(request, str(self.obj.pk)) + + self.assertTrue(response.context_data["pagination_required"]) + self.assertEqual(len(response.context_data["log_entries"]), 5) + + def test_auditlog_history_view_page_parameter(self): + # Create more log entries by updating the object + for i in range(10): + self.obj.text = f"Updated text {i}" + self.obj.save() + + request = RequestFactory().get("/?p=2") + request.user = self.user + + response = self.admin.auditlog_history_view(request, str(self.obj.pk)) + + # Should be on page 2 + self.assertEqual(response.context_data["log_entries"].number, 2) + + def test_auditlog_history_view_context_data(self): + request = RequestFactory().get("/") + request.user = self.user + + response = self.admin.auditlog_history_view(request, str(self.obj.pk)) + + context = response.context_data + required_keys = [ + "title", + "module_name", + "page_range", + "page_var", + "pagination_required", + "object", + "opts", + "log_entries", + ] + + for key in required_keys: + self.assertIn(key, context) + + self.assertIn(str(self.obj), context["title"]) + self.assertEqual(context["object"], self.obj) + self.assertEqual(context["opts"], self.obj._meta) + + def test_auditlog_history_view_extra_context(self): + request = RequestFactory().get("/") + request.user = self.user + + extra_context = {"extra_key": "extra_value"} + response = self.admin.auditlog_history_view( + request, str(self.obj.pk), extra_context + ) + + self.assertIn("extra_key", response.context_data) + self.assertEqual(response.context_data["extra_key"], "extra_value") + + def test_auditlog_history_view_template(self): + request = RequestFactory().get("/") + request.user = self.user + + response = self.admin.auditlog_history_view(request, str(self.obj.pk)) + + self.assertEqual(response.template_name, self.admin.auditlog_history_template) + + def test_auditlog_history_view_log_entries_ordering(self): + self.obj.text = "First update" + self.obj.save() + self.obj.text = "Second update" + self.obj.save() + + request = RequestFactory().get("/") + request.user = self.user + + response = self.admin.auditlog_history_view(request, str(self.obj.pk)) + + log_entries = list(response.context_data["log_entries"]) + self.assertGreaterEqual(log_entries[0].timestamp, log_entries[1].timestamp) + + def test_get_list_display_with_auditlog_link(self): + self.admin.show_auditlog_history_link = True + list_display = self.admin.get_list_display(RequestFactory().get("/")) + + self.assertIn("auditlog_link", list_display) + + self.admin.show_auditlog_history_link = False + list_display = self.admin.get_list_display(RequestFactory().get("/")) + + self.assertNotIn("auditlog_link", list_display) + + def test_get_urls_includes_auditlog_url(self): + urls = self.admin.get_urls() + + self.assertGreater(len(urls), 0) + + url_names = [ + url.name for url in urls if hasattr(url, "name") and url.name is not None + ] + auditlog_urls = [name for name in url_names if "auditlog" in name] + self.assertGreater(len(auditlog_urls), 0) + + @patch("auditlog.mixins.reverse") + def test_auditlog_link(self, mock_reverse): + """Test that auditlog_link method returns correct HTML link.""" + # Mock the reverse function to return a test URL + expected_url = f"/admin/test_app/simplemodel/{self.obj.pk}/auditlog/" + mock_reverse.return_value = expected_url + + link_html = self.admin.auditlog_link(self.obj) + + self.assertIsInstance(link_html, str) + + self.assertIn("", link_html) + + self.assertIn(expected_url, link_html) + + opts = self.obj._meta + expected_url_name = f"admin:{opts.app_label}_{opts.model_name}_auditlog" + mock_reverse.assert_called_once_with(expected_url_name, args=[self.obj.pk]) diff --git a/docs/source/usage.rst b/docs/source/usage.rst index ab35fdd..4f9dda6 100644 --- a/docs/source/usage.rst +++ b/docs/source/usage.rst @@ -551,3 +551,26 @@ Django Admin integration When ``auditlog`` is added to your ``INSTALLED_APPS`` setting a customized admin class is active providing an enhanced Django Admin interface for log entries. + +Audit log history view +---------------------- + +.. versionadded:: 3.2.2 + +Use ``AuditlogHistoryAdminMixin`` to add a "View" link in the admin changelist for accessing each object's audit history:: + + from auditlog.mixins import AuditlogHistoryAdminMixin + + @admin.register(MyModel) + class MyModelAdmin(AuditlogHistoryAdminMixin, admin.ModelAdmin): + show_auditlog_history_link = True + +The history page displays paginated log entries with user, timestamp, action, and field changes. Override +``auditlog_history_template`` to customize the page layout. + +The mixin provides the following configuration options: + +- ``show_auditlog_history_link``: Set to ``True`` to display the "View" link in the admin changelist +- ``auditlog_history_template``: Template to use for rendering the history page (default: ``auditlog/object_history.html``) +- ``auditlog_history_per_page``: Number of log entries to display per page (default: 10) +