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>
This commit is contained in:
Youngkwang Yang 2025-08-05 20:02:43 +09:00 committed by GitHub
parent d4d9f287a6
commit 9ef8cf2476
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 761 additions and 82 deletions

View file

@ -2,6 +2,10 @@
## Next Release ## 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) ## 3.2.1 (2025-07-03)
#### Improvements #### Improvements

View file

@ -1,17 +1,21 @@
from urllib.parse import unquote
from django import urls as urlresolvers from django import urls as urlresolvers
from django.conf import settings from django.conf import settings
from django.contrib import admin from django.contrib import admin
from django.core.exceptions import FieldDoesNotExist from django.contrib.admin.views.main import PAGE_VAR
from django.forms.utils import pretty_name from django.core.exceptions import PermissionDenied
from django.http import HttpRequest 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.urls.exceptions import NoReverseMatch
from django.utils.html import format_html, format_html_join from django.utils.html import format_html
from django.utils.safestring import mark_safe from django.utils.text import capfirst
from django.utils.timezone import is_aware, localtime from django.utils.timezone import is_aware, localtime
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from auditlog.models import LogEntry 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 from auditlog.signals import accessed
MAX = 75 MAX = 75
@ -68,55 +72,7 @@ class LogEntryAdminMixin:
@admin.display(description=_("Changes")) @admin.display(description=_("Changes"))
def msg(self, obj): def msg(self, obj):
changes = obj.changes_dict return render_logentry_changes_html(obj)
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("<table>")
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("</table>")
if m2m_changes:
msg.append("<table>")
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("<br>"),
"{}",
[(value,) for value in change["objects"]],
)
msg.append(
format_html(
"<tr><td>{}</td><td>{}</td><td>{}</td><td>{}</td></tr>",
i,
self.field_verbose_name(obj, field),
change["operation"],
change_html,
)
)
msg.append("</table>")
return mark_safe("".join(msg))
@admin.display(description="Correlation ID") @admin.display(description="Correlation ID")
def cid_url(self, obj): def cid_url(self, obj):
@ -127,43 +83,95 @@ class LogEntryAdminMixin:
'<a href="{}" title="{}">{}</a>', url, self.CID_TITLE, cid '<a href="{}" title="{}">{}</a>', url, self.CID_TITLE, cid
) )
def _format_header(self, *labels):
return format_html(
"".join(["<tr>", "<th>{}</th>" * len(labels), "</tr>"]), *labels
)
def _format_line(self, *values):
return format_html(
"".join(["<tr>", "<td>{}</td>" * len(values), "</tr>"]), *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): def _add_query_parameter(self, key: str, value: str):
full_path = self.request.get_full_path() full_path = self.request.get_full_path()
delimiter = "&" if "?" in full_path else "?" delimiter = "&" if "?" in full_path else "?"
return f"{full_path}{delimiter}{key}={value}" 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: class LogAccessMixin:
def render_to_response(self, context, **response_kwargs): def render_to_response(self, context, **response_kwargs):
obj = self.get_object() obj = self.get_object()
accessed.send(obj.__class__, instance=obj) accessed.send(obj.__class__, instance=obj)
return super().render_to_response(context, **response_kwargs) 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(
"<path:object_id>/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('<a href="{}">{}</a>', url, _("View"))

94
auditlog/render.py Normal file
View file

@ -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"<table>{''.join(rows)}</table>"
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("<br>"),
"{}",
[(obj,) for obj in change["objects"]],
)
rows.append(_format_row(i, field_name, change["operation"], objects_html))
return f"<table>{''.join(rows)}</table>"
def _format_header(*labels):
return format_html("".join(["<tr>", "<th>{}</th>" * len(labels), "</tr>"]), *labels)
def _format_row(*values):
return format_html("".join(["<tr>", "<td>{}</td>" * len(values), "</tr>"]), *values)

View file

@ -0,0 +1,18 @@
{% load i18n auditlog_tags %}
<div class="auditlog-entry">
<div class="entry-header">
<div class="entry-meta">
<span class="entry-timestamp">{{ entry.timestamp|date:"DATETIME_FORMAT" }}</span>
<span class="entry-user">{{ entry.actor|default:'system' }}</span>
<span class="entry-action">{{ entry.get_action_display }}</span>
</div>
</div>
<div class="entry-content">
{% if entry.action == entry.Action.DELETE or entry.action == entry.Action.ACCESS %}
<span class="no-changes">{% trans 'No field changes' %}</span>
{% else %}
{{ entry|render_logentry_changes_html|safe }}
{% endif %}
</div>
</div>

View file

@ -0,0 +1,160 @@
{% extends "admin/base_site.html" %}
{% load i18n admin_urls static %}
{% block extrahead %}
{{ block.super }}
<style type="text/css">
.auditlog-entries {
display: flex;
flex-direction: column;
gap: 15px;
max-width: 1200px;
}
.auditlog-entry {
border: 1px solid var(--hairline-color, #e1e1e1);
border-radius: 4px;
}
.entry-header {
padding: 8px 12px;
border-bottom: 1px solid var(--hairline-color, #e1e1e1);
}
.entry-meta {
display: flex;
flex-direction: row;
gap: 16px;
}
.entry-timestamp {
font-weight: 600;
font-size: 0.9em;
}
.entry-user {
font-size: 0.9em;
}
.entry-action {
padding: 1px 6px;
border-radius: 3px;
font-size: 0.8em;
font-weight: 500;
border: 1px solid var(--hairline-color, #e1e1e1);
}
.entry-content {
padding: 12px;
}
.no-changes {
font-style: italic;
opacity: 0.7;
font-size: 0.9em;
}
/* Table styling */
.entry-content table {
width: auto;
min-width: 100%;
border-collapse: collapse;
margin: 6px 0;
font-size: 0.9em;
}
.entry-content table th,
.entry-content table td {
padding: 6px 8px;
text-align: left;
vertical-align: top;
border: 1px solid var(--hairline-color, #e1e1e1);
white-space: nowrap;
}
.entry-content table th {
font-weight: 600;
font-size: 0.85em;
}
.entry-content table td {
max-width: 200px;
word-wrap: break-word;
white-space: normal;
}
.entry-content table + table {
margin-top: 8px;
}
/* Pagination styling */
.pagination {
margin-top: 16px;
text-align: center;
}
.pagination a,
.pagination span {
display: inline-block;
padding: 4px 8px;
margin: 0 2px;
border: 1px solid var(--hairline-color, #e1e1e1);
text-decoration: none;
border-radius: 3px;
}
.pagination .current {
font-weight: 600;
border-width: 2px;
}
.pagination-info {
text-align: center;
margin-top: 8px;
opacity: 0.7;
font-size: 0.9em;
}
/* Responsive */
@media (max-width: 768px) {
.auditlog-entries {
max-width: 100%;
}
.entry-content {
padding: 6px;
}
}
</style>
{% endblock %}
{% block breadcrumbs %}
<div class="breadcrumbs">
<a href="{% url 'admin:index' %}">{% translate 'Home' %}</a>
&rsaquo; <a href="{% url 'admin:app_list' app_label=opts.app_label %}">{{ opts.app_config.verbose_name }}</a>
&rsaquo; <a href="{% url opts|admin_urlname:'changelist' %}">{{ module_name }}</a>
&rsaquo; <a href="{% url opts|admin_urlname:'change' object.pk|admin_urlquote %}">{{ object|truncatewords:'18' }}</a>
&rsaquo; {% translate 'Audit log' %}
</div>
{% endblock %}
{% block content %}
<div id="content-main">
<div id="auditlog-history" class="module">
{% if log_entries %}
<div class="auditlog-entries">
{% for entry in log_entries %}
{% include "auditlog/entry_detail.html" with entry=entry %}
{% endfor %}
</div>
{% if pagination_required %}
{% include "auditlog/pagination.html" with page_obj=log_entries page_var=page_var %}
{% endif %}
{% else %}
<p>{% trans 'No log entries found.' %}</p>
{% endif %}
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,16 @@
{% load i18n %}
<div class="pagination">
{% for i in page_obj.paginator.page_range %}
{% if i == page_obj.paginator.ELLIPSIS %}
<span>...</span>
{% elif i == page_obj.number %}
<span class="current">{{ i }}</span>
{% else %}
<a href="?{{ page_var }}={{ i }}">{{ i }}</a>
{% endif %}
{% endfor %}
</div>
<p class="pagination-info">
{{ page_obj.paginator.count }} {% blocktranslate count counter=page_obj.paginator.count %}entry{% plural %}entries{% endblocktranslate %}
</p>

View file

View file

@ -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)

View file

@ -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("<table>", result)
self.assertIn("<th>#</th>", result)
self.assertIn("<th>Field</th>", result)
self.assertIn("<th>From</th>", result)
self.assertIn("<th>To</th>", 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("<table>", result)
self.assertIn("<th>#</th>", result)
self.assertIn("<th>Relationship</th>", result)
self.assertIn("<th>Action</th>", result)
self.assertIn("<th>Objects</th>", 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("<table>")
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("<table>", 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("<table>", result)
self.assertIn("old value", result)
self.assertIn("True", result)
self.assertIn("None", result)

175
auditlog_tests/test_view.py Normal file
View file

@ -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("<a href=", link_html)
self.assertIn("View</a>", 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])

View file

@ -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 When ``auditlog`` is added to your ``INSTALLED_APPS`` setting a customized admin class is active providing an enhanced
Django Admin interface for log entries. 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)