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 130f4806..7240f7c7 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 dbdfc5b4..9341295c 100644 --- a/auditlog/conf.py +++ b/auditlog/conf.py @@ -45,3 +45,8 @@ settings.AUDITLOG_DISABLE_REMOTE_ADDR = getattr( settings, "AUDITLOG_DISABLE_REMOTE_ADDR", False ) + +# 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 644c6ce5..e16c4819 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): """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 e57ef2c6..b3e7ac49 100644 --- a/auditlog/management/commands/auditlogflush.py +++ b/auditlog/management/commands/auditlogflush.py @@ -2,7 +2,9 @@ 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/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 1ae8dbcc..93afbd97 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 @@ -385,6 +376,7 @@ class Action: objects = LogEntryManager() class Meta: + abstract = True get_latest_by = "timestamp" ordering = ["-timestamp"] verbose_name = _("log entry") @@ -544,6 +536,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 @@ -564,7 +571,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" @@ -592,8 +599,8 @@ def bulk_related_objects(self, objs, using=DEFAULT_DB_ALIAS): changes_func = None -def _changes_func() -> Callable[[LogEntry], Dict]: - def json_then_text(instance: LogEntry) -> Dict: +def _changes_func() -> Callable[[AbstractLogEntry], Dict]: + def json_then_text(instance: AbstractLogEntry) -> Dict: if instance.changes: return instance.changes elif instance.changes_text: @@ -601,7 +608,7 @@ def json_then_text(instance: LogEntry) -> Dict: return json.loads(instance.changes_text) return {} - def default(instance: LogEntry) -> Dict: + def default(instance: AbstractLogEntry) -> Dict: return instance.changes or {} if settings.AUDITLOG_USE_TEXT_CHANGES_IF_JSON_IS_NOT_PRESENT: diff --git a/auditlog/receivers.py b/auditlog/receivers.py index 02405d53..d85f9242 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.objects.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, @@ -161,14 +161,14 @@ def log_m2m_changes(signal, action, **kwargs): changed_queryset = kwargs["model"].objects.filter(pk__in=kwargs["pk_set"]) 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 3f1f8f30..5ef4b669 100644 --- a/auditlog/registry.py +++ b/auditlog/registry.py @@ -38,7 +38,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/models.py b/auditlog_tests/models.py index ffacc838..1b7db351 100644 --- a/auditlog_tests/models.py +++ b/auditlog_tests/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) @@ -356,6 +356,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 ba29ce77..7d7f9300 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 7df115e8..b1f07c36 100644 --- a/auditlog_tests/test_two_step_json_migration.py +++ b/auditlog_tests/test_two_step_json_migration.py @@ -5,9 +5,11 @@ from django.core.management import CommandError, call_command from django.test import TestCase, override_settings -from auditlog.models import LogEntry +from auditlog import get_logentry_model from auditlog_tests.models import SimpleModel +LogEntry = get_logentry_model() + class TwoStepMigrationTest(TestCase): def test_use_text_changes_first(self): diff --git a/auditlog_tests/tests.py b/auditlog_tests/tests.py index 0ff960b4..bd9fddc1 100644 --- a/auditlog_tests/tests.py +++ b/auditlog_tests/tests.py @@ -27,12 +27,13 @@ from django.utils.encoding import smart_str from django.utils.translation import gettext_lazy as _ +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 from auditlog_tests.fixtures.custom_get_cid import get_cid as custom_get_cid @@ -42,6 +43,7 @@ AutoManyRelatedModel, CharfieldTextfieldModel, ChoicesFieldModel, + CustomLogEntryModel, DateTimeFieldModel, JSONModel, ManyRelatedModel, @@ -65,6 +67,8 @@ UUIDPrimaryKeyModel, ) +LogEntry = get_logentry_model() + class SimpleModelTest(TestCase): def setUp(self): @@ -1242,7 +1246,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()), 27) + self.assertEqual(len(self.test_auditlog.get_models()), 28) def test_register_models_register_model_with_attrs(self): self.test_auditlog._register_models( @@ -2627,7 +2631,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 @@ -2733,3 +2737,12 @@ def test_get_changes_for_missing_model(self): history = self.obj.history.latest() self.assertEqual(history.changes_dict["text"][1], self.obj.text) self.assertEqual(history.changes_display_dict["text"][1], self.obj.text) + + +class SwappableLogEntryModelTest(TestCase): + + @override_settings(AUDITLOG_LOGENTRY_MODEL="auditlog_tests.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)