From ad5257fe7125cb46e37b4a19f643c55016684662 Mon Sep 17 00:00:00 2001 From: Cleiton Lima Date: Mon, 7 Oct 2024 15:21:17 -0300 Subject: [PATCH 1/2] Initial --- auditlog/__init__.py | 29 ++++++++++++++++ auditlog/admin.py | 4 ++- auditlog/conf.py | 5 +++ auditlog/context.py | 5 ++- auditlog/diff.py | 4 ++- auditlog/management/commands/auditlogflush.py | 4 ++- .../commands/auditlogmigratejson.py | 4 ++- auditlog/migrations/0001_initial.py | 1 + auditlog/mixins.py | 4 ++- auditlog/models.py | 33 +++++++++++-------- auditlog/receivers.py | 16 ++++----- auditlog/registry.py | 2 +- auditlog_tests/test_app/models.py | 6 +++- auditlog_tests/test_settings.py | 12 +++++++ .../test_two_step_json_migration.py | 4 ++- auditlog_tests/tests.py | 18 ++++++++-- 16 files changed, 118 insertions(+), 33 deletions(-) diff --git a/auditlog/__init__.py b/auditlog/__init__.py index 0fd293e3..9cbd7634 100644 --- a/auditlog/__init__.py +++ b/auditlog/__init__.py @@ -1,3 +1,32 @@ +from __future__ import annotations + from importlib.metadata import version +from typing import TYPE_CHECKING + +from django.apps import apps as django_apps +from django.conf import settings +from django.core.exceptions import ImproperlyConfigured + +if TYPE_CHECKING: + from auditlog.models import AbstractLogEntry __version__ = version("django-auditlog") + + +def get_logentry_model() -> type[AbstractLogEntry]: + """ + Return the LogEntry model that is active in this project. + """ + 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 595ec4c6..240d6093 100644 --- a/auditlog/admin.py +++ b/auditlog/admin.py @@ -4,9 +4,11 @@ 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 dceedd19..57fec3b2 100644 --- a/auditlog/conf.py +++ b/auditlog/conf.py @@ -55,3 +55,8 @@ settings.AUDITLOG_CHANGE_DISPLAY_TRUNCATE_LENGTH = getattr( settings, "AUDITLOG_CHANGE_DISPLAY_TRUNCATE_LENGTH", 140 ) + +# Swap default model +settings.AUDITLOG_LOGENTRY_MODEL = getattr( + settings, "AUDITLOG_LOGENTRY_MODEL", "auditlog.LogEntry" +) diff --git a/auditlog/context.py b/auditlog/context.py index a5f916c3..10907260 100644 --- a/auditlog/context.py +++ b/auditlog/context.py @@ -6,12 +6,15 @@ 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) +LogEntry = get_logentry_model() + + @contextlib.contextmanager def set_actor(actor, remote_addr=None, remote_port=None): """Connect a signal receiver with current user attached.""" diff --git a/auditlog/diff.py b/auditlog/diff.py index 2fd44a97..f14cba85 100644 --- a/auditlog/diff.py +++ b/auditlog/diff.py @@ -20,7 +20,9 @@ def track_field(field): :return: Whether the given field should be tracked. :rtype: bool """ - from auditlog.models import LogEntry + from auditlog import get_logentry_model + + LogEntry = get_logentry_model() # Do not track many to many relations if field.many_to_many: diff --git a/auditlog/management/commands/auditlogflush.py b/auditlog/management/commands/auditlogflush.py index 7a42aa67..36032eaa 100644 --- a/auditlog/management/commands/auditlogflush.py +++ b/auditlog/management/commands/auditlogflush.py @@ -3,7 +3,9 @@ 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 86caf25b..8aa991ab 100644 --- a/auditlog/management/commands/auditlogmigratejson.py +++ b/auditlog/management/commands/auditlogmigratejson.py @@ -4,7 +4,9 @@ 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): diff --git a/auditlog/migrations/0001_initial.py b/auditlog/migrations/0001_initial.py index 6821c2d5..67e5343a 100644 --- a/auditlog/migrations/0001_initial.py +++ b/auditlog/migrations/0001_initial.py @@ -67,6 +67,7 @@ class Migration(migrations.Migration): ), ], options={ + "swappable": "AUDITLOG_LOGENTRY_MODEL", "ordering": ["-timestamp"], "get_latest_by": "timestamp", "verbose_name": "log entry", diff --git a/auditlog/mixins.py b/auditlog/mixins.py index aa1ab517..9a5bfd51 100644 --- a/auditlog/mixins.py +++ b/auditlog/mixins.py @@ -10,12 +10,14 @@ 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.registry import auditlog from auditlog.signals import accessed MAX = 75 +LogEntry = get_logentry_model() + class LogEntryAdminMixin: request: HttpRequest diff --git a/auditlog/models.py b/auditlog/models.py index a0029a1b..dcce7bc5 100644 --- a/auditlog/models.py +++ b/auditlog/models.py @@ -23,6 +23,7 @@ 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 mask_str DEFAULT_OBJECT_REPR = "" @@ -107,7 +108,7 @@ def log_m2m_changes( except ObjectDoesNotExist: object_repr = DEFAULT_OBJECT_REPR kwargs.setdefault("object_repr", object_repr) - kwargs.setdefault("action", LogEntry.Action.UPDATE) + kwargs.setdefault("action", get_logentry_model().Action.UPDATE) if isinstance(pk, int): kwargs.setdefault("object_id", pk) @@ -302,17 +303,7 @@ def _mask_serialized_fields( return data -class LogEntry(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. - It holds the action performed and the fields that were changed in the transaction. - - If AuditlogMiddleware is used, the actor will be set automatically. Keep in mind that - editing / re-saving LogEntry instances may set the actor to a wrong value - editing LogEntry - instances is not recommended (and it should not be necessary). - """ - +class AbstractLogEntry(models.Model): class Action: """ The actions that Auditlog distinguishes: creating, updating and deleting objects. Viewing objects @@ -391,6 +382,7 @@ class Action: objects = LogEntryManager() class Meta: + abstract = True get_latest_by = "timestamp" ordering = ["-timestamp"] verbose_name = _("log entry") @@ -550,6 +542,21 @@ def _get_changes_display_for_fk_field( return f"Deleted '{field.related_model.__name__}' ({value})" +class LogEntry(AbstractLogEntry): + """ + 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. + It holds the action performed and the fields that were changed in the transaction. + + If AuditlogMiddleware is used, the actor will be set automatically. Keep in mind that + editing / re-saving LogEntry instances may set the actor to a wrong value - editing LogEntry + instances is not recommended (and it should not be necessary). + """ + + 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 @@ -570,7 +577,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 b1fc817b..82b12974 100644 --- a/auditlog/receivers.py +++ b/auditlog/receivers.py @@ -2,9 +2,9 @@ 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, @@ -57,7 +57,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, @@ -75,7 +75,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, @@ -91,7 +91,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, @@ -121,7 +121,7 @@ def _create_log_entry( ) if force_log or changes: - log_entry = LogEntry.objects.log_create( + log_entry = get_logentry_model().objects.log_create( instance, action=action, changes=changes, @@ -163,14 +163,14 @@ def log_m2m_changes(signal, action, **kwargs): ) if action in ["post_add"]: - LogEntry.objects.log_m2m_changes( + get_logentry_model().objects.log_m2m_changes( changed_queryset, kwargs["instance"], "add", field_name, ) elif action in ["post_remove", "post_clear"]: - LogEntry.objects.log_m2m_changes( + get_logentry_model().objects.log_m2m_changes( changed_queryset, kwargs["instance"], "delete", diff --git a/auditlog/registry.py b/auditlog/registry.py index b472f725..a6f47cee 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/test_app/models.py b/auditlog_tests/test_app/models.py index 38d6966f..424eaa6f 100644 --- a/auditlog_tests/test_app/models.py +++ b/auditlog_tests/test_app/models.py @@ -4,7 +4,7 @@ from django.core.serializers.json import DjangoJSONEncoder from django.db import models -from auditlog.models import AuditlogHistoryField +from auditlog.models import AbstractLogEntry, AuditlogHistoryField from auditlog.registry import AuditlogModelRegistry, auditlog m2m_only_auditlog = AuditlogModelRegistry(create=False, update=False, delete=False) @@ -424,6 +424,10 @@ class AutoManyRelatedModel(models.Model): related = models.ManyToManyField(SimpleModel) +class CustomLogEntryModel(AbstractLogEntry): + pass + + auditlog.register(AltPrimaryKeyModel) auditlog.register(UUIDPrimaryKeyModel) auditlog.register(ModelPrimaryKeyModel) diff --git a/auditlog_tests/test_settings.py b/auditlog_tests/test_settings.py index f707a862..fa7e9415 100644 --- a/auditlog_tests/test_settings.py +++ b/auditlog_tests/test_settings.py @@ -4,6 +4,16 @@ import os + +class DisableMigrations: + + def __contains__(self, item): + return True + + def __getitem__(self, item): + return None + + DEBUG = True SECRET_KEY = "test" @@ -62,3 +72,5 @@ USE_TZ = True DEFAULT_AUTO_FIELD = "django.db.models.AutoField" + +# MIGRATION_MODULES = DisableMigrations() diff --git a/auditlog_tests/test_two_step_json_migration.py b/auditlog_tests/test_two_step_json_migration.py index 2c66bced..dd0659f2 100644 --- a/auditlog_tests/test_two_step_json_migration.py +++ b/auditlog_tests/test_two_step_json_migration.py @@ -6,7 +6,9 @@ from django.test import TestCase, override_settings from test_app.models import SimpleModel -from auditlog.models import LogEntry +from auditlog import get_logentry_model + +LogEntry = get_logentry_model() class TwoStepMigrationTest(TestCase): diff --git a/auditlog_tests/tests.py b/auditlog_tests/tests.py index c5c9fe8c..ee3bbcf4 100644 --- a/auditlog_tests/tests.py +++ b/auditlog_tests/tests.py @@ -33,6 +33,7 @@ AutoManyRelatedModel, CharfieldTextfieldModel, ChoicesFieldModel, + CustomLogEntryModel, DateTimeFieldModel, JSONModel, ManyRelatedModel, @@ -59,15 +60,17 @@ 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.diff import 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): @@ -1276,7 +1279,7 @@ def test_register_models_register_app(self): self.assertTrue(self.test_auditlog.contains(SimpleExcludeModel)) self.assertTrue(self.test_auditlog.contains(ChoicesFieldModel)) - self.assertEqual(len(self.test_auditlog.get_models()), 32) + self.assertEqual(len(self.test_auditlog.get_models()), 33) def test_register_models_register_model_with_attrs(self): self.test_auditlog._register_models( @@ -2728,7 +2731,7 @@ def post_log_receiver(sender, instance, action, error, log_entry, **_kwargs): self.assertSignals(LogEntry.Action.DELETE) - @patch("auditlog.receivers.LogEntry.objects") + @patch("auditlog.models.LogEntry.objects") def test_signals_errors(self, log_entry_objects_mock): class CustomSignalError(BaseException): pass @@ -2861,3 +2864,12 @@ def test_update_public(self): log = LogEntry.objects.get_for_object(self.public).first() self.assertEqual(log.action, LogEntry.Action.UPDATE) self.assertEqual(log.changes_dict["name"], ["Public", "Updated"]) + + +class SwappableLogEntryModelTest(TestCase): + + @override_settings(AUDITLOG_LOGENTRY_MODEL="test_app.CustomLogEntryModel") + def test_custom_log_entry_model(self): + self.assertEqual(get_logentry_model(), CustomLogEntryModel) + SimpleModel.objects.create(text="Hi!") + self.assertEqual(CustomLogEntryModel.objects.count(), 1) From 6184e6bd0f0061139edb3480573e6f8f9d7f2fa4 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 31 Mar 2025 11:56:42 +0000 Subject: [PATCH 2/2] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- auditlog_tests/tests.py | 1 + 1 file changed, 1 insertion(+) diff --git a/auditlog_tests/tests.py b/auditlog_tests/tests.py index ee3bbcf4..527de14b 100644 --- a/auditlog_tests/tests.py +++ b/auditlog_tests/tests.py @@ -72,6 +72,7 @@ LogEntry = get_logentry_model() + class SimpleModelTest(TestCase): def setUp(self): self.obj = self.make_object()