mirror of
https://github.com/jazzband/django-auditlog.git
synced 2026-03-16 22:20:26 +00:00
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:
parent
d4d9f287a6
commit
9ef8cf2476
11 changed files with 761 additions and 82 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
94
auditlog/render.py
Normal 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)
|
||||||
18
auditlog/templates/auditlog/entry_detail.html
Normal file
18
auditlog/templates/auditlog/entry_detail.html
Normal 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>
|
||||||
160
auditlog/templates/auditlog/object_history.html
Normal file
160
auditlog/templates/auditlog/object_history.html
Normal 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>
|
||||||
|
› <a href="{% url 'admin:app_list' app_label=opts.app_label %}">{{ opts.app_config.verbose_name }}</a>
|
||||||
|
› <a href="{% url opts|admin_urlname:'changelist' %}">{{ module_name }}</a>
|
||||||
|
› <a href="{% url opts|admin_urlname:'change' object.pk|admin_urlquote %}">{{ object|truncatewords:'18' }}</a>
|
||||||
|
› {% 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 %}
|
||||||
16
auditlog/templates/auditlog/pagination.html
Normal file
16
auditlog/templates/auditlog/pagination.html
Normal 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>
|
||||||
0
auditlog/templatetags/__init__.py
Normal file
0
auditlog/templatetags/__init__.py
Normal file
16
auditlog/templatetags/auditlog_tags.py
Normal file
16
auditlog/templatetags/auditlog_tags.py
Normal 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)
|
||||||
165
auditlog_tests/test_render.py
Normal file
165
auditlog_tests/test_render.py
Normal 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
175
auditlog_tests/test_view.py
Normal 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])
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue