Add CustomLogEntry model support and update tests: (#764)

* Add CustomLogEntry model support and update tests:

- Added support for CustomLogEntry data model to extend django-auditlog capabilities

- Updated existing test cases to align with new model structure and data handling logic

- Added new test cases to validate CustomLogEntry behavior, model registration, and signal handling

- Ensured backward compatibility with existing LogEntry model where applicable

* Update auditlog/__init__.py

Co-authored-by: Youngkwang Yang <me@youngkwang.dev>

* run only one custom model test matrix (#761)

---------

Co-authored-by: Youngkwang Yang <me@youngkwang.dev>
This commit is contained in:
mostafaeftekharizadeh 2025-11-19 12:16:43 +03:30 committed by GitHub
parent 0e58a9d2d5
commit 7d13fd4ba8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 441 additions and 63 deletions

View file

@ -1,3 +1,25 @@
from __future__ import annotations
from importlib.metadata import version
from django.apps import apps as django_apps
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
__version__ = version("django-auditlog")
def get_logentry_model():
try:
return django_apps.get_model(
settings.AUDITLOG_LOGENTRY_MODEL, require_ready=False
)
except ValueError:
raise ImproperlyConfigured(
"AUDITLOG_LOGENTRY_MODEL must be of the form 'app_label.model_name'"
)
except LookupError:
raise ImproperlyConfigured(
"AUDITLOG_LOGENTRY_MODEL refers to model '%s' that has not been installed"
% settings.AUDITLOG_LOGENTRY_MODEL
)

View file

@ -4,9 +4,11 @@ from django.contrib import admin
from django.contrib.auth import get_user_model
from django.utils.translation import gettext_lazy as _
from auditlog import get_logentry_model
from auditlog.filters import CIDFilter, ResourceTypeFilter
from auditlog.mixins import LogEntryAdminMixin
from auditlog.models import LogEntry
LogEntry = get_logentry_model()
@admin.register(LogEntry)

View file

@ -63,6 +63,10 @@ settings.AUDITLOG_STORE_JSON_CHANGES = getattr(
settings.AUDITLOG_MASK_CALLABLE = getattr(settings, "AUDITLOG_MASK_CALLABLE", None)
settings.AUDITLOG_LOGENTRY_MODEL = getattr(
settings, "AUDITLOG_LOGENTRY_MODEL", "auditlog.LogEntry"
)
# Use base model managers instead of default model managers
settings.AUDITLOG_USE_BASE_MANAGER = getattr(
settings, "AUDITLOG_USE_BASE_MANAGER", False

View file

@ -6,7 +6,7 @@ from functools import partial
from django.contrib.auth import get_user_model
from django.db.models.signals import pre_save
from auditlog.models import LogEntry
from auditlog import get_logentry_model
auditlog_value = ContextVar("auditlog_value")
auditlog_disabled = ContextVar("auditlog_disabled", default=False)
@ -14,23 +14,33 @@ auditlog_disabled = ContextVar("auditlog_disabled", default=False)
@contextlib.contextmanager
def set_actor(actor, remote_addr=None, remote_port=None):
"""Connect a signal receiver with current user attached."""
# Initialize thread local storage
context_data = {
"signal_duid": ("set_actor", time.time()),
"actor": actor,
"remote_addr": remote_addr,
"remote_port": remote_port,
}
return call_context_manager(context_data)
@contextlib.contextmanager
def set_extra_data(context_data):
return call_context_manager(context_data)
def call_context_manager(context_data):
"""Connect a signal receiver with current user attached."""
LogEntry = get_logentry_model()
# Initialize thread local storage
context_data["signal_duid"] = ("set_actor", time.time())
auditlog_value.set(context_data)
# Connect signal for automatic logging
set_actor = partial(
_set_actor,
user=actor,
set_extra_data = partial(
_set_extra_data,
signal_duid=context_data["signal_duid"],
)
pre_save.connect(
set_actor,
set_extra_data,
sender=LogEntry,
dispatch_uid=context_data["signal_duid"],
weak=False,
@ -47,11 +57,26 @@ def set_actor(actor, remote_addr=None, remote_port=None):
pre_save.disconnect(sender=LogEntry, dispatch_uid=auditlog["signal_duid"])
def _set_actor(user, sender, instance, signal_duid, **kwargs):
def _set_actor(auditlog, instance, sender):
LogEntry = get_logentry_model()
auth_user_model = get_user_model()
if "actor" in auditlog:
actor = auditlog.get("actor")
if (
sender == LogEntry
and isinstance(actor, auth_user_model)
and instance.actor is None
):
instance.actor = actor
instance.actor_email = getattr(actor, "email", None)
def _set_extra_data(sender, instance, signal_duid, **kwargs):
"""Signal receiver with extra 'user' and 'signal_duid' kwargs.
This function becomes a valid signal receiver when it is curried with the actor and a dispatch id.
"""
LogEntry = get_logentry_model()
try:
auditlog = auditlog_value.get()
except LookupError:
@ -59,17 +84,15 @@ def _set_actor(user, sender, instance, signal_duid, **kwargs):
else:
if signal_duid != auditlog["signal_duid"]:
return
auth_user_model = get_user_model()
if (
sender == LogEntry
and isinstance(user, auth_user_model)
and instance.actor is None
):
instance.actor = user
instance.actor_email = getattr(user, "email", None)
instance.remote_addr = auditlog["remote_addr"]
instance.remote_port = auditlog["remote_port"]
_set_actor(auditlog, instance, sender)
for key in auditlog:
if key != "actor" and hasattr(LogEntry, key):
if callable(auditlog[key]):
setattr(instance, key, auditlog[key]())
else:
setattr(instance, key, auditlog[key])
@contextlib.contextmanager

View file

@ -9,6 +9,8 @@ from django.utils import timezone as django_timezone
from django.utils.encoding import smart_str
from django.utils.module_loading import import_string
from auditlog import get_logentry_model
def track_field(field):
"""
@ -21,7 +23,6 @@ def track_field(field):
:return: Whether the given field should be tracked.
:rtype: bool
"""
from auditlog.models import LogEntry
# Do not track many to many relations
if field.many_to_many:
@ -30,7 +31,7 @@ def track_field(field):
# Do not track relations to LogEntry
if (
getattr(field, "remote_field", None) is not None
and field.remote_field.model == LogEntry
and field.remote_field.model == get_logentry_model()
):
return False

View file

@ -3,7 +3,9 @@ import datetime
from django.core.management.base import BaseCommand
from django.db import connection
from auditlog.models import LogEntry
from auditlog import get_logentry_model
LogEntry = get_logentry_model()
class Command(BaseCommand):

View file

@ -4,7 +4,9 @@ from django.conf import settings
from django.core.management import CommandError, CommandParser
from django.core.management.base import BaseCommand
from auditlog.models import LogEntry
from auditlog import get_logentry_model
LogEntry = get_logentry_model()
class Command(BaseCommand):
@ -125,8 +127,8 @@ class Command(BaseCommand):
def postgres():
with connection.cursor() as cursor:
cursor.execute(
"""
UPDATE auditlog_logentry
f"""
UPDATE {LogEntry._meta.db_table}
SET changes="changes_text"::jsonb
WHERE changes_text IS NOT NULL
AND changes_text <> ''

View file

@ -2,7 +2,7 @@ from django.conf import settings
from django.contrib.auth import get_user_model
from auditlog.cid import set_cid
from auditlog.context import set_actor
from auditlog.context import set_extra_data
class AuditlogMiddleware:
@ -54,12 +54,17 @@ class AuditlogMiddleware:
return user
return None
def __call__(self, request):
remote_addr = self._get_remote_addr(request)
remote_port = self._get_remote_port(request)
user = self._get_actor(request)
def get_extra_data(self, request):
context_data = {}
context_data["remote_addr"] = self._get_remote_addr(request)
context_data["remote_port"] = self._get_remote_port(request)
context_data["actor"] = self._get_actor(request)
return context_data
def __call__(self, request):
set_cid(request)
with set_actor(actor=user, remote_addr=remote_addr, remote_port=remote_port):
with set_extra_data(context_data=self.get_extra_data(request)):
return self.get_response(request)

View file

@ -14,10 +14,12 @@ 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 import get_logentry_model
from auditlog.render import get_field_verbose_name, render_logentry_changes_html
from auditlog.signals import accessed
LogEntry = get_logentry_model()
MAX = 75

View file

@ -24,6 +24,7 @@ from django.utils import timezone as django_timezone
from django.utils.encoding import smart_str
from django.utils.translation import gettext_lazy as _
from auditlog import get_logentry_model
from auditlog.diff import get_mask_function
DEFAULT_OBJECT_REPR = "<error forming object repr>"
@ -304,7 +305,7 @@ class LogEntryManager(models.Manager):
return data
class LogEntry(models.Model):
class AbstractLogEntry(models.Model):
"""
Represents an entry in the audit log. The content type is saved along with the textual and numeric
(if available) primary key, as well as the textual representation of the object when it was saved.
@ -393,6 +394,7 @@ class LogEntry(models.Model):
objects = LogEntryManager()
class Meta:
abstract = True
get_latest_by = "timestamp"
ordering = ["-timestamp"]
verbose_name = _("log entry")
@ -562,6 +564,11 @@ class LogEntry(models.Model):
return f"Deleted '{field.related_model.__name__}' ({value})"
class LogEntry(AbstractLogEntry):
class Meta(AbstractLogEntry.Meta):
swappable = "AUDITLOG_LOGENTRY_MODEL"
class AuditlogHistoryField(GenericRelation):
"""
A subclass of py:class:`django.contrib.contenttypes.fields.GenericRelation` that sets some default
@ -582,7 +589,7 @@ class AuditlogHistoryField(GenericRelation):
"""
def __init__(self, pk_indexable=True, delete_related=False, **kwargs):
kwargs["to"] = LogEntry
kwargs["to"] = get_logentry_model()
if pk_indexable:
kwargs["object_id_field"] = "object_id"

View file

@ -2,9 +2,10 @@ from functools import wraps
from django.conf import settings
from auditlog import get_logentry_model
from auditlog.context import auditlog_disabled
from auditlog.diff import model_instance_diff
from auditlog.models import LogEntry, _get_manager_from_settings
from auditlog.models import _get_manager_from_settings
from auditlog.signals import post_log, pre_log
@ -38,7 +39,7 @@ def log_create(sender, instance, created, **kwargs):
"""
if created:
_create_log_entry(
action=LogEntry.Action.CREATE,
action=get_logentry_model().Action.CREATE,
instance=instance,
sender=sender,
diff_old=None,
@ -58,7 +59,7 @@ def log_update(sender, instance, **kwargs):
update_fields = kwargs.get("update_fields", None)
old = _get_manager_from_settings(sender).filter(pk=instance.pk).first()
_create_log_entry(
action=LogEntry.Action.UPDATE,
action=get_logentry_model().Action.UPDATE,
instance=instance,
sender=sender,
diff_old=old,
@ -77,7 +78,7 @@ def log_delete(sender, instance, **kwargs):
"""
if instance.pk is not None:
_create_log_entry(
action=LogEntry.Action.DELETE,
action=get_logentry_model().Action.DELETE,
instance=instance,
sender=sender,
diff_old=instance,
@ -94,7 +95,7 @@ def log_access(sender, instance, **kwargs):
"""
if instance.pk is not None:
_create_log_entry(
action=LogEntry.Action.ACCESS,
action=get_logentry_model().Action.ACCESS,
instance=instance,
sender=sender,
diff_old=None,
@ -122,6 +123,7 @@ def _create_log_entry(
if any(item[1] is False for item in pre_log_results):
return
LogEntry = get_logentry_model()
error = None
log_entry = None
@ -169,6 +171,7 @@ def make_log_m2m_changes(field_name):
"""Handle m2m_changed and call LogEntry.objects.log_m2m_changes as needed."""
if action not in ["post_add", "post_clear", "post_remove"]:
return
LogEntry = get_logentry_model()
model_manager = _get_manager_from_settings(kwargs["model"])

View file

@ -29,7 +29,7 @@ class AuditlogModelRegistry:
A registry that keeps track of the models that use Auditlog to track changes.
"""
DEFAULT_EXCLUDE_MODELS = ("auditlog.LogEntry", "admin.LogEntry")
DEFAULT_EXCLUDE_MODELS = (settings.AUDITLOG_LOGENTRY_MODEL, "admin.LogEntry")
def __init__(
self,

View file

@ -0,0 +1,5 @@
from django.apps import AppConfig
class CustomLogEntryConfig(AppConfig):
name = "custom_logentry_app"

View file

@ -0,0 +1,138 @@
# Generated by Django 4.2.25 on 2025-10-14 04:17
import django.db.models.deletion
import django.utils.timezone
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("contenttypes", "0002_remove_content_type_name"),
]
operations = [
migrations.CreateModel(
name="CustomLogEntryModel",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"object_pk",
models.CharField(
db_index=True, max_length=255, verbose_name="object pk"
),
),
(
"object_id",
models.BigIntegerField(
blank=True, db_index=True, null=True, verbose_name="object id"
),
),
("object_repr", models.TextField(verbose_name="object representation")),
("serialized_data", models.JSONField(null=True)),
(
"action",
models.PositiveSmallIntegerField(
choices=[
(0, "create"),
(1, "update"),
(2, "delete"),
(3, "access"),
],
db_index=True,
verbose_name="action",
),
),
(
"changes_text",
models.TextField(blank=True, verbose_name="change message"),
),
("changes", models.JSONField(null=True, verbose_name="change message")),
(
"cid",
models.CharField(
blank=True,
db_index=True,
max_length=255,
null=True,
verbose_name="Correlation ID",
),
),
(
"remote_addr",
models.GenericIPAddressField(
blank=True, null=True, verbose_name="remote address"
),
),
(
"remote_port",
models.PositiveIntegerField(
blank=True, null=True, verbose_name="remote port"
),
),
(
"timestamp",
models.DateTimeField(
db_index=True,
default=django.utils.timezone.now,
verbose_name="timestamp",
),
),
(
"additional_data",
models.JSONField(
blank=True, null=True, verbose_name="additional data"
),
),
(
"actor_email",
models.CharField(
blank=True,
max_length=254,
null=True,
verbose_name="actor email",
),
),
("role", models.CharField(blank=True, max_length=100, null=True)),
(
"actor",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="+",
to=settings.AUTH_USER_MODEL,
verbose_name="actor",
),
),
(
"content_type",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="+",
to="contenttypes.contenttype",
verbose_name="content type",
),
),
],
options={
"verbose_name": "log entry",
"verbose_name_plural": "log entries",
"ordering": ["-timestamp"],
"get_latest_by": "timestamp",
"abstract": False,
},
),
]

View file

@ -0,0 +1,7 @@
from django.db import models
from auditlog.models import AbstractLogEntry
class CustomLogEntryModel(AbstractLogEntry):
role = models.CharField(max_length=100, null=True, blank=True)

View file

@ -0,0 +1,12 @@
from auditlog.middleware import AuditlogMiddleware
class CustomAuditlogMiddleware(AuditlogMiddleware):
"""
Custom Middleware to couple the request's user role to log items.
"""
def get_extra_data(self, request):
context_data = super().get_extra_data(request)
context_data["role"] = "Role 1"
return context_data

View file

@ -121,6 +121,9 @@ class AuditlogFlushWithTruncateTest(TransactionTestCase):
self.mock_input = input_patcher.start()
self.addCleanup(input_patcher.stop)
def _fixture_teardown(self):
call_command("flush", verbosity=0, interactive=False, allow_cascade=True)
def make_object(self):
return SimpleModel.objects.create(text="I am a simple model.")

View file

@ -1,9 +1,11 @@
from django.test import TestCase
from test_app.models import SimpleModel
from auditlog.models import LogEntry
from auditlog import get_logentry_model
from auditlog.templatetags.auditlog_tags import render_logentry_changes_html
LogEntry = get_logentry_model()
class RenderChangesTest(TestCase):

View file

@ -18,6 +18,7 @@ INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.staticfiles",
"django.contrib.postgres",
"custom_logentry_app",
"auditlog",
"test_app",
]
@ -28,9 +29,14 @@ MIDDLEWARE = [
"django.contrib.sessions.middleware.SessionMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"auditlog.middleware.AuditlogMiddleware",
]
if os.environ.get("AUDITLOG_LOGENTRY_MODEL", None):
MIDDLEWARE = MIDDLEWARE + ["auditlog.middleware.AuditlogMiddleware"]
else:
MIDDLEWARE = MIDDLEWARE + ["middleware.CustomAuditlogMiddleware"]
if TEST_DB_BACKEND == "postgresql":
DATABASES = {
"default": {
@ -100,3 +106,5 @@ ROOT_URLCONF = "test_app.urls"
USE_TZ = True
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
AUDITLOG_LOGENTRY_MODEL = os.environ.get("AUDITLOG_LOGENTRY_MODEL", "auditlog.LogEntry")

View file

@ -8,7 +8,9 @@ from django.test import TestCase, override_settings
from django.test.utils import skipIf
from test_app.models import SimpleModel
from auditlog.models import LogEntry
from auditlog import get_logentry_model
LogEntry = get_logentry_model()
class TwoStepMigrationTest(TestCase):
@ -119,7 +121,10 @@ class AuditlogMigrateJsonTest(TestCase):
self.make_logentry()
# Act
with patch("auditlog.models.LogEntry.objects.bulk_update") as bulk_update:
LogEntry = get_logentry_model()
path = f"{LogEntry.__module__}.{LogEntry.__name__}.objects.bulk_update"
with patch(path) as bulk_update:
outbuf, errbuf = self.call_command("-b=1")
call_count = bulk_update.call_count

View file

@ -1,9 +1,11 @@
from django.test import TestCase, override_settings
from test_app.models import JSONModel, NullableFieldModel, RelatedModel, SimpleModel
from auditlog.models import LogEntry
from auditlog import get_logentry_model
from auditlog.registry import AuditlogModelRegistry
LogEntry = get_logentry_model()
class JSONForChangesTest(TestCase):

View file

@ -4,7 +4,7 @@ import json
import random
import warnings
from datetime import timezone
from unittest import mock
from unittest import mock, skipIf
from unittest.mock import patch
import freezegun
@ -17,6 +17,8 @@ from django.contrib.auth import get_user_model
from django.contrib.auth.models import AnonymousUser, User
from django.contrib.contenttypes.models import ContentType
from django.core import management
from django.core.exceptions import ImproperlyConfigured
from django.core.management import call_command
from django.db import models
from django.db.models import JSONField, Value
from django.db.models.functions import Now
@ -62,15 +64,18 @@ from test_app.models import (
UUIDPrimaryKeyModel,
)
from auditlog import get_logentry_model
from auditlog.admin import LogEntryAdmin
from auditlog.cid import get_cid
from auditlog.context import disable_auditlog, set_actor
from auditlog.context import disable_auditlog, set_actor, set_extra_data
from auditlog.diff import mask_str, model_instance_diff
from auditlog.middleware import AuditlogMiddleware
from auditlog.models import DEFAULT_OBJECT_REPR, LogEntry
from auditlog.models import DEFAULT_OBJECT_REPR
from auditlog.registry import AuditlogModelRegistry, AuditLogRegistrationError, auditlog
from auditlog.signals import post_log, pre_log
LogEntry = get_logentry_model()
class SimpleModelTest(TestCase):
def setUp(self):
@ -260,7 +265,7 @@ class NoActorMixin:
self.assertIsNone(log_entry.actor)
class WithActorMixin:
class WithActorMixinBase:
sequence = itertools.count()
def setUp(self):
@ -279,10 +284,6 @@ class WithActorMixin:
self.assertIsNotNone(auditlog_entries, msg="All auditlog entries are deleted.")
super().tearDown()
def make_object(self):
with set_actor(self.user):
return super().make_object()
def check_create_log_entry(self, obj, log_entry):
super().check_create_log_entry(obj, log_entry)
self.assertEqual(log_entry.actor, self.user)
@ -307,6 +308,12 @@ class WithActorMixin:
self.assertEqual(log_entry.actor_email, self.user.email)
class WithActorMixin(WithActorMixinBase):
def make_object(self):
with set_actor(self.user):
return super().make_object()
class AltPrimaryKeyModelBase(SimpleModelTest):
def make_object(self):
return AltPrimaryKeyModel.objects.create(
@ -371,6 +378,10 @@ class ModelPrimaryKeyModelWithActorTest(WithActorMixin, ModelPrimaryKeyModelBase
# Must inherit from TransactionTestCase to use self.assertNumQueries.
class ModelPrimaryKeyTest(TransactionTestCase):
def _fixture_teardown(self):
call_command("flush", verbosity=0, interactive=False, allow_cascade=True)
def test_get_pk_value(self):
"""
Test that the primary key can be retrieved without additional database queries.
@ -1741,21 +1752,24 @@ class AdminPanelTest(TestCase):
)
self.site = AdminSite()
self.admin = LogEntryAdmin(LogEntry, self.site)
self.admin_path_prefix = (
f"admin/{LogEntry._meta.app_label}/{LogEntry._meta.model_name}"
)
with freezegun.freeze_time("2022-08-01 12:00:00Z"):
self.obj = SimpleModel.objects.create(text="For admin logentry test")
def test_auditlog_admin(self):
self.client.force_login(self.user)
log_pk = self.obj.history.latest().pk
res = self.client.get("/admin/auditlog/logentry/")
res = self.client.get(f"/{self.admin_path_prefix}/")
self.assertEqual(res.status_code, 200)
res = self.client.get("/admin/auditlog/logentry/add/")
res = self.client.get(f"/{self.admin_path_prefix}/add/")
self.assertEqual(res.status_code, 403)
res = self.client.get(f"/admin/auditlog/logentry/{log_pk}/", follow=True)
res = self.client.get(f"/{self.admin_path_prefix}/{log_pk}/", follow=True)
self.assertEqual(res.status_code, 200)
res = self.client.get(f"/admin/auditlog/logentry/{log_pk}/delete/")
res = self.client.get(f"/{self.admin_path_prefix}/{log_pk}/delete/")
self.assertEqual(res.status_code, 403)
res = self.client.get(f"/admin/auditlog/logentry/{log_pk}/history/")
res = self.client.get(f"/{self.admin_path_prefix}/{log_pk}/history/")
self.assertEqual(res.status_code, 200)
def test_created_timezone(self):
@ -1785,7 +1799,7 @@ class AdminPanelTest(TestCase):
def test_cid(self):
self.client.force_login(self.user)
expected_response = (
'<a href="/admin/auditlog/logentry/?cid=123" '
f'<a href="/{self.admin_path_prefix}/?cid=123" '
'title="Click to filter by records with this correlation id">123</a>'
)
@ -1793,7 +1807,7 @@ class AdminPanelTest(TestCase):
log_entry.cid = "123"
log_entry.save()
res = self.client.get("/admin/auditlog/logentry/")
res = self.client.get(f"/{self.admin_path_prefix}/")
self.assertEqual(res.status_code, 200)
self.assertIn(expected_response, res.rendered_content)
@ -1801,7 +1815,7 @@ class AdminPanelTest(TestCase):
log = self.obj.history.latest()
obj_pk = self.obj.pk
delete_log_request = RequestFactory().post(
f"/admin/auditlog/logentry/{log.pk}/delete/"
f"/{self.admin_path_prefix}/{log.pk}/delete/"
)
delete_log_request.resolver_match = resolve(delete_log_request.path)
delete_log_request.user = self.user
@ -2798,7 +2812,7 @@ class SignalTests(TestCase):
self.assertSignals(LogEntry.Action.DELETE)
@patch("auditlog.receivers.LogEntry.objects")
@patch.object(LogEntry, "objects")
def test_signals_errors(self, log_entry_objects_mock):
class CustomSignalError(BaseException):
pass
@ -3120,3 +3134,72 @@ class CustomMaskModelTest(TestCase):
"****7654",
msg="The custom masking function should be used in serialized data",
)
class WithExtraDataMixin(WithActorMixinBase):
def get_context_data(self):
return {}
def make_object(self):
with set_extra_data(context_data=self.get_context_data()):
return super().make_object()
class ExtraDataTest(WithExtraDataMixin, SimpleModelTest):
def get_context_data(self):
return {
"actor": self.user,
}
class ExtraDataWithRoleTest(WithExtraDataMixin, SimpleModelTest):
def get_context_data(self):
return {
"actor": self.user,
"role": "admin",
}
@skipIf(
settings.AUDITLOG_LOGENTRY_MODEL == "auditlog.LogEntry",
"Do not run on defualt log entry model",
)
def test_extra_data_role(self):
log = self.obj.history.first()
self.assertEqual(log.role, "admin")
class ExtraDataWithRoleLazyLoadTest(WithExtraDataMixin, SimpleModelTest):
def get_context_data(self):
return {
"actor": self.user,
"role": lambda: "admin",
}
@skipIf(
settings.AUDITLOG_LOGENTRY_MODEL == "auditlog.LogEntry",
"Do not run on defualt log entry model",
)
def test_extra_data_role(self):
log = self.obj.history.first()
self.assertEqual(log.role, "admin")
class GetLogEntryModelTest(TestCase):
"""Test the get_logentry_model function."""
def get_model_name(self):
model = get_logentry_model()
return f"{model._meta.app_label}.{model._meta.object_name}"
def test_logentry_model(self):
self.assertEqual(self.get_model_name(), settings.AUDITLOG_LOGENTRY_MODEL)
@override_settings(AUDITLOG_LOGENTRY_MODEL="LogEntry")
def test_invalid_logentry_model_name(self):
with self.assertRaises(ImproperlyConfigured):
get_logentry_model()
@override_settings(AUDITLOG_LOGENTRY_MODEL="test_app2.LogEntry")
def test_invalid_appname(self):
with self.assertRaises(ImproperlyConfigured):
get_logentry_model()

View file

@ -407,6 +407,43 @@ may see inaccurate data in log entries, recording changes to a seemingly
.. versionadded:: 3.4.0
**AUDITLOG_LOGENTRY_MODEL**
This configuration variable allows you to specify a custom model to be used instead of the default
:py:class:`auditlog.models.LogEntry` model for storing audit records.
By default, Auditlog stores change records in the built-in ``LogEntry`` model.
If you need to store additional information in each log entry (for example, a user role, request metadata,
or any other contextual data), you can define your own model by subclassing
:py:class:`auditlog.models.AbstractLogEntry` and configure it using this setting.
.. code-block:: python
from django.db import models
from auditlog.models import AbstractLogEntry
class CustomLogEntryModel(AbstractLogEntry):
role = models.CharField(max_length=100, null=True, blank=True)
Then, in your project settings:
.. code-block:: python
AUDITLOG_LOGENTRY_MODEL = 'custom_log_app.CustomLogEntryModel'
Once defined, Auditlog will automatically use the specified model for all future log entries instead
of the default one.
.. note::
- The custom model **must** inherit from :py:class:`auditlog.models.AbstractLogEntry`.
- All fields and behaviors defined in :py:class:`AbstractLogEntry` should remain intact to ensure compatibility.
- The app label and model name in ``AUDITLOG_LOGENTRY_MODEL`` must follow Djangos standard dotted notation
(for example, ``"app_name.ModelName"``).
.. versionadded:: 3.5.0
Custom LogEntry model configuration via ``AUDITLOG_LOGENTRY_MODEL``
Actors
------

View file

@ -1,5 +1,6 @@
[tox]
envlist =
{py312}-customlogmodel-django52
{py310,py311}-django42
{py310,py311,py312}-django50
{py310,py311,py312,py313}-django51
@ -9,9 +10,11 @@ envlist =
py310-lint
py310-checkmigrations
[testenv]
setenv =
COVERAGE_FILE={toxworkdir}/.coverage.{envname}.{env:TEST_DB_BACKEND}
customlogmodel: AUDITLOG_LOGENTRY_MODEL = custom_logentry_app.CustomLogEntryModel
changedir = auditlog_tests
commands =
coverage run --source auditlog ./manage.py test