From 677a59b0285d789dc35e41b8cd09ef80f42a8987 Mon Sep 17 00:00:00 2001 From: Mostafa Eftekhari Date: Tue, 14 Oct 2025 14:33:42 +0330 Subject: [PATCH] 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 --- auditlog/__init__.py | 22 +++ auditlog/admin.py | 4 +- auditlog/conf.py | 4 + auditlog/context.py | 61 +++++--- auditlog/diff.py | 5 +- auditlog/management/commands/auditlogflush.py | 4 +- .../commands/auditlogmigratejson.py | 8 +- auditlog/middleware.py | 18 ++- auditlog/mixins.py | 4 +- auditlog/models.py | 11 +- auditlog/receivers.py | 12 +- auditlog/registry.py | 2 +- .../custom_logentry_app/__init__.py | 0 auditlog_tests/custom_logentry_app/apps.py | 5 + .../migrations/0001_initial.py | 138 ++++++++++++++++++ .../migrations/__init__.py | 0 auditlog_tests/custom_logentry_app/models.py | 7 + auditlog_tests/custom_logentry_app/urls.py | 0 auditlog_tests/custom_logentry_app/views.py | 0 auditlog_tests/middleware.py | 12 ++ auditlog_tests/test_commands.py | 3 + auditlog_tests/test_render.py | 4 +- auditlog_tests/test_settings.py | 10 +- .../test_two_step_json_migration.py | 9 +- auditlog_tests/test_use_json_for_changes.py | 4 +- auditlog_tests/tests.py | 109 ++++++++++++-- tox.ini | 11 +- 27 files changed, 400 insertions(+), 67 deletions(-) create mode 100644 auditlog_tests/custom_logentry_app/__init__.py create mode 100644 auditlog_tests/custom_logentry_app/apps.py create mode 100644 auditlog_tests/custom_logentry_app/migrations/0001_initial.py create mode 100644 auditlog_tests/custom_logentry_app/migrations/__init__.py create mode 100644 auditlog_tests/custom_logentry_app/models.py create mode 100644 auditlog_tests/custom_logentry_app/urls.py create mode 100644 auditlog_tests/custom_logentry_app/views.py create mode 100644 auditlog_tests/middleware.py diff --git a/auditlog/__init__.py b/auditlog/__init__.py index 0fd293e..97c065f 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_ENTRY_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 b151b24..049275a 100644 --- a/auditlog/conf.py +++ b/auditlog/conf.py @@ -62,3 +62,7 @@ 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" +) 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 fc98987..af975a8 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 bd01da3..bd414c2 100644 --- a/auditlog/middleware.py +++ b/auditlog/middleware.py @@ -4,7 +4,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: @@ -56,12 +56,18 @@ 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 01b54a0..0c94ea2 100644 --- a/auditlog/models.py +++ b/auditlog/models.py @@ -23,6 +23,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 = "" @@ -303,7 +304,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. @@ -392,6 +393,7 @@ class LogEntry(models.Model): objects = LogEntryManager() class Meta: + abstract = True get_latest_by = "timestamp" ordering = ["-timestamp"] verbose_name = _("log entry") @@ -559,6 +561,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 @@ -579,7 +586,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 525675a..88cfe69 100644 --- a/auditlog/receivers.py +++ b/auditlog/receivers.py @@ -2,9 +2,9 @@ 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 from auditlog.signals import post_log, pre_log @@ -38,7 +38,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 +58,7 @@ def log_update(sender, instance, **kwargs): update_fields = kwargs.get("update_fields", None) old = sender._default_manager.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 +77,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 +94,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 +122,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 +170,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() if action == "post_clear": changed_queryset = kwargs["model"]._default_manager.all() diff --git a/auditlog/registry.py b/auditlog/registry.py index c8ca907..176fd73 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 7c2e1cc..6ace14e 100644 --- a/auditlog_tests/tests.py +++ b/auditlog_tests/tests.py @@ -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 @@ -60,15 +62,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): @@ -258,7 +263,7 @@ class NoActorMixin: self.assertIsNone(log_entry.actor) -class WithActorMixin: +class WithActorMixinBase: sequence = itertools.count() def setUp(self): @@ -277,10 +282,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) @@ -305,6 +306,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( @@ -369,6 +376,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. @@ -1739,21 +1750,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): @@ -1783,7 +1797,7 @@ class AdminPanelTest(TestCase): def test_cid(self): self.client.force_login(self.user) expected_response = ( - '123' ) @@ -1791,7 +1805,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) @@ -1799,7 +1813,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 @@ -2796,7 +2810,7 @@ class SignalTests(TestCase): self.assertSignals(LogEntry.Action.DELETE) - @patch("auditlog.receivers.LogEntry.objects") + @patch(f"{LogEntry.__module__}.{LogEntry.__name__}.objects") def test_signals_errors(self, log_entry_objects_mock): class CustomSignalError(BaseException): pass @@ -2988,3 +3002,66 @@ 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", + } + + def test_extra_data_role(self): + log = self.obj.history.first() + if settings.AUDITLOG_LOGENTRY_MODEL != "auditlog.LogEntry": + self.assertEqual(log.role, "admin") + + +class ExtraDataWithRoleLazyLoadTest(WithExtraDataMixin, SimpleModelTest): + def get_context_data(self): + return { + "actor": self.user, + "role": lambda: "admin", + } + + def test_extra_data_role(self): + log = self.obj.history.first() + if settings.AUDITLOG_LOGENTRY_MODEL != "auditlog.LogEntry": + 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/tox.ini b/tox.ini index e804268..b9befac 100644 --- a/tox.ini +++ b/tox.ini @@ -1,10 +1,10 @@ [tox] envlist = - {py39,py310,py311}-django42 - {py310,py311,py312}-django50 - {py310,py311,py312,py313}-django51 - {py310,py311,py312,py313}-django52 - {py312,py313}-djangomain + {py39,py310,py311}-{customlogmodel,defaultlogmodel}-django42 + {py310,py311,py312}-{customlogmodel,defaultlogmodel}-django50 + {py310,py311,py312,py313}-{customlogmodel,defaultlogmodel}-django51 + {py310,py311,py312,py313}-{customlogmodel,defaultlogmodel}-django52 + {py312,py313}-{customlogmodel,defaultlogmodel}-djangomain py39-docs py39-lint py39-checkmigrations @@ -12,6 +12,7 @@ envlist = [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