diff --git a/auditlog/__init__.py b/auditlog/__init__.py index 0fd293e..3f39bfd 100644 --- a/auditlog/__init__.py +++ b/auditlog/__init__.py @@ -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 + ) diff --git a/auditlog/admin.py b/auditlog/admin.py index 595ec4c..240d609 100644 --- a/auditlog/admin.py +++ b/auditlog/admin.py @@ -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) diff --git a/auditlog/conf.py b/auditlog/conf.py index a2895b8..0839d32 100644 --- a/auditlog/conf.py +++ b/auditlog/conf.py @@ -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 diff --git a/auditlog/context.py b/auditlog/context.py index cdd6e23..2eb3058 100644 --- a/auditlog/context.py +++ b/auditlog/context.py @@ -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 diff --git a/auditlog/diff.py b/auditlog/diff.py index 72d1a76..0d69749 100644 --- a/auditlog/diff.py +++ b/auditlog/diff.py @@ -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 diff --git a/auditlog/management/commands/auditlogflush.py b/auditlog/management/commands/auditlogflush.py index 8231e27..045d643 100644 --- a/auditlog/management/commands/auditlogflush.py +++ b/auditlog/management/commands/auditlogflush.py @@ -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): diff --git a/auditlog/management/commands/auditlogmigratejson.py b/auditlog/management/commands/auditlogmigratejson.py index 86caf25..8b1dbeb 100644 --- a/auditlog/management/commands/auditlogmigratejson.py +++ b/auditlog/management/commands/auditlogmigratejson.py @@ -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 <> '' diff --git a/auditlog/middleware.py b/auditlog/middleware.py index 295448e..c27f485 100644 --- a/auditlog/middleware.py +++ b/auditlog/middleware.py @@ -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) diff --git a/auditlog/mixins.py b/auditlog/mixins.py index b0cdc45..8ae203c 100644 --- a/auditlog/mixins.py +++ b/auditlog/mixins.py @@ -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 diff --git a/auditlog/models.py b/auditlog/models.py index bfde86f..edc5dd2 100644 --- a/auditlog/models.py +++ b/auditlog/models.py @@ -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 = "" @@ -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" diff --git a/auditlog/receivers.py b/auditlog/receivers.py index 7c38d3c..184bc26 100644 --- a/auditlog/receivers.py +++ b/auditlog/receivers.py @@ -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"]) diff --git a/auditlog/registry.py b/auditlog/registry.py index 835acd1..033ec96 100644 --- a/auditlog/registry.py +++ b/auditlog/registry.py @@ -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, diff --git a/auditlog_tests/custom_logentry_app/__init__.py b/auditlog_tests/custom_logentry_app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/auditlog_tests/custom_logentry_app/apps.py b/auditlog_tests/custom_logentry_app/apps.py new file mode 100644 index 0000000..d0a144b --- /dev/null +++ b/auditlog_tests/custom_logentry_app/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class CustomLogEntryConfig(AppConfig): + name = "custom_logentry_app" diff --git a/auditlog_tests/custom_logentry_app/migrations/0001_initial.py b/auditlog_tests/custom_logentry_app/migrations/0001_initial.py new file mode 100644 index 0000000..8ba23c9 --- /dev/null +++ b/auditlog_tests/custom_logentry_app/migrations/0001_initial.py @@ -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, + }, + ), + ] diff --git a/auditlog_tests/custom_logentry_app/migrations/__init__.py b/auditlog_tests/custom_logentry_app/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/auditlog_tests/custom_logentry_app/models.py b/auditlog_tests/custom_logentry_app/models.py new file mode 100644 index 0000000..34c1cd5 --- /dev/null +++ b/auditlog_tests/custom_logentry_app/models.py @@ -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) diff --git a/auditlog_tests/custom_logentry_app/urls.py b/auditlog_tests/custom_logentry_app/urls.py new file mode 100644 index 0000000..e69de29 diff --git a/auditlog_tests/custom_logentry_app/views.py b/auditlog_tests/custom_logentry_app/views.py new file mode 100644 index 0000000..e69de29 diff --git a/auditlog_tests/middleware.py b/auditlog_tests/middleware.py new file mode 100644 index 0000000..2a50bda --- /dev/null +++ b/auditlog_tests/middleware.py @@ -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 diff --git a/auditlog_tests/test_commands.py b/auditlog_tests/test_commands.py index da3838d..f083970 100644 --- a/auditlog_tests/test_commands.py +++ b/auditlog_tests/test_commands.py @@ -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.") diff --git a/auditlog_tests/test_render.py b/auditlog_tests/test_render.py index 0cd9794..3fcd41e 100644 --- a/auditlog_tests/test_render.py +++ b/auditlog_tests/test_render.py @@ -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): diff --git a/auditlog_tests/test_settings.py b/auditlog_tests/test_settings.py index e7be7b8..14fc847 100644 --- a/auditlog_tests/test_settings.py +++ b/auditlog_tests/test_settings.py @@ -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") diff --git a/auditlog_tests/test_two_step_json_migration.py b/auditlog_tests/test_two_step_json_migration.py index da1c19e..2a35dce 100644 --- a/auditlog_tests/test_two_step_json_migration.py +++ b/auditlog_tests/test_two_step_json_migration.py @@ -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 diff --git a/auditlog_tests/test_use_json_for_changes.py b/auditlog_tests/test_use_json_for_changes.py index e3d6087..8bbbdd6 100644 --- a/auditlog_tests/test_use_json_for_changes.py +++ b/auditlog_tests/test_use_json_for_changes.py @@ -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): diff --git a/auditlog_tests/tests.py b/auditlog_tests/tests.py index fb1f434..f12ef9d 100644 --- a/auditlog_tests/tests.py +++ b/auditlog_tests/tests.py @@ -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 = ( - '123' ) @@ -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() diff --git a/docs/source/usage.rst b/docs/source/usage.rst index bc65d8a..9dc4a95 100644 --- a/docs/source/usage.rst +++ b/docs/source/usage.rst @@ -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 Django’s standard dotted notation + (for example, ``"app_name.ModelName"``). + +.. versionadded:: 3.5.0 + Custom LogEntry model configuration via ``AUDITLOG_LOGENTRY_MODEL`` + Actors ------ diff --git a/tox.ini b/tox.ini index a123535..96f17e4 100644 --- a/tox.ini +++ b/tox.ini @@ -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