diff --git a/CHANGELOG.md b/CHANGELOG.md index 4815afce..376ac95f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ## 3.0.0-beta.4 (2024-01-02) #### Improvements +- feat: Add `AUDITLOG_REGISTRY` settings attribute to have multiple auditlogs with custom configurations ([#623](https://github.com/jazzband/django-auditlog/pull/623)) - feat: Excluding ip address when `AUDITLOG_DISABLE_REMOTE_ADDR` is set to True ([#620](https://github.com/jazzband/django-auditlog/pull/620)) - feat: If any receiver returns False, no logging will be made. This can be useful if logging should be conditionally enabled / disabled ([#590](https://github.com/jazzband/django-auditlog/pull/590)) - Django: Confirm Django 5.0 support ([#598](https://github.com/jazzband/django-auditlog/pull/598)) diff --git a/auditlog/conf.py b/auditlog/conf.py index dbdfc5b4..910b4573 100644 --- a/auditlog/conf.py +++ b/auditlog/conf.py @@ -1,5 +1,6 @@ from django.conf import settings + # Register all models when set to True settings.AUDITLOG_INCLUDE_ALL_MODELS = getattr( settings, "AUDITLOG_INCLUDE_ALL_MODELS", False @@ -45,3 +46,8 @@ settings.AUDITLOG_DISABLE_REMOTE_ADDR = getattr( settings, "AUDITLOG_DISABLE_REMOTE_ADDR", False ) + +# List of paths to auditlogs +settings.AUDITLOG_REGISTRY = getattr( + settings, "AUDITLOG_REGISTRY", ["auditlog.registry.auditlog"] +) diff --git a/auditlog/diff.py b/auditlog/diff.py index eece7254..3cf84067 100644 --- a/auditlog/diff.py +++ b/auditlog/diff.py @@ -124,7 +124,7 @@ def model_instance_diff( field values as value. :rtype: dict """ - from auditlog.registry import auditlog + from auditlog.registry import AuditlogRegistry if not (old is None or isinstance(old, Model)): raise TypeError("The supplied old instance is not a valid model instance.") @@ -135,13 +135,13 @@ def model_instance_diff( if old is not None and new is not None: fields = set(old._meta.fields + new._meta.fields) - model_fields = auditlog.get_model_fields(new._meta.model) + model_fields = AuditlogRegistry.get_model_fields(new._meta.model) elif old is not None: fields = set(get_fields_in_model(old)) - model_fields = auditlog.get_model_fields(old._meta.model) + model_fields = AuditlogRegistry.get_model_fields(old._meta.model) elif new is not None: fields = set(get_fields_in_model(new)) - model_fields = auditlog.get_model_fields(new._meta.model) + model_fields = AuditlogRegistry.get_model_fields(new._meta.model) else: fields = set() model_fields = None diff --git a/auditlog/mixins.py b/auditlog/mixins.py index aa1ab517..f0ffbb3f 100644 --- a/auditlog/mixins.py +++ b/auditlog/mixins.py @@ -11,7 +11,7 @@ from django.utils.translation import gettext_lazy as _ from auditlog.models import LogEntry -from auditlog.registry import auditlog +from auditlog.registry import AuditlogRegistry from auditlog.signals import accessed MAX = 75 @@ -142,7 +142,7 @@ def field_verbose_name(self, obj, field_name: str): if model is None: return field_name try: - model_fields = auditlog.get_model_fields(model._meta.model) + model_fields = AuditlogRegistry.get_model_fields(model._meta.model) mapping_field_name = model_fields["mapping_fields"].get(field_name) if mapping_field_name: return mapping_field_name diff --git a/auditlog/models.py b/auditlog/models.py index c28cb1bc..9af4d1d0 100644 --- a/auditlog/models.py +++ b/auditlog/models.py @@ -222,7 +222,9 @@ def _get_pk_value(self, instance): return pk def _get_serialized_data_or_none(self, instance): - from auditlog.registry import auditlog + from auditlog.registry import AuditlogRegistry + + auditlog = AuditlogRegistry.get_auditlog(model=instance.__class__) opts = auditlog.get_serialize_options(instance.__class__) if not opts["serialize_data"]: @@ -436,10 +438,12 @@ def changes_display_dict(self): """ :return: The changes recorded in this log entry intended for display to users as a dictionary object. """ - from auditlog.registry import auditlog + from auditlog.registry import AuditlogRegistry # Get the model and model_fields, but gracefully handle the case where the model no longer exists model = self.content_type.model_class() + auditlog = AuditlogRegistry.get_auditlog(model=model) + model_fields = None if auditlog.contains(model._meta.model): model_fields = auditlog.get_model_fields(model._meta.model) diff --git a/auditlog/registry.py b/auditlog/registry.py index 3f1f8f30..8a789c39 100644 --- a/auditlog/registry.py +++ b/auditlog/registry.py @@ -13,6 +13,7 @@ ) from django.apps import apps +from django.core.exceptions import ImproperlyConfigured from django.db.models import ManyToManyField, Model from django.db.models.base import ModelBase from django.db.models.signals import ( @@ -22,6 +23,7 @@ post_save, pre_save, ) +from django.utils.module_loading import import_string from auditlog.conf import settings from auditlog.signals import accessed @@ -363,3 +365,54 @@ def register_from_settings(self): auditlog = AuditlogModelRegistry() + + +def get_default_auditlogs(): + return get_auditlogs(settings.AUDITLOG_REGISTRY) + + +def get_auditlogs(auditlog_config): + auditlogs = [] + + for auditlog in auditlog_config: + try: + audit_logger = import_string(auditlog) + except ImportError: + msg = ( + "The module in NAME could not be imported: %s. Check your " + "AUDITLOG_REGISTRY setting." + ) + raise ImproperlyConfigured(msg % auditlog["NAME"]) + else: + auditlogs.append(audit_logger) + return auditlogs + + +class AuditlogRegistry: + """ + A registry that keeps track of AuditlogModelRegistry instances. + """ + + _registry = {} + + @classmethod + def get_auditlog(cls, model: ModelBase): + """ + Retrieve the auditlog object associated with a given model. + """ + auditlogs = get_default_auditlogs() + return next( + (auditlog for auditlog in auditlogs if auditlog._registry.get(model)), None + ) + + @classmethod + def get_model_fields(cls, model: ModelBase): + """ + Wrapper of AuditlogModelRegistry.get_model_fields(). + """ + auditlog = cls.get_auditlog(model) + model_fields = auditlog.get_model_fields(model) + return model_fields + + +auditlog_registry = AuditlogRegistry() diff --git a/auditlog_tests/admin.py b/auditlog_tests/admin.py index 858596e3..e649ee26 100644 --- a/auditlog_tests/admin.py +++ b/auditlog_tests/admin.py @@ -1,6 +1,6 @@ from django.contrib import admin -from auditlog.registry import auditlog +from auditlog.registry import auditlog, get_default_auditlogs -for model in auditlog.get_models(): - admin.site.register(model) +for auditlog in get_default_auditlogs(): + [admin.site.register(model) for model in auditlog.get_models()] diff --git a/auditlog_tests/apps.py b/auditlog_tests/apps.py index 2bcc0807..a977e472 100644 --- a/auditlog_tests/apps.py +++ b/auditlog_tests/apps.py @@ -3,3 +3,12 @@ class AuditlogTestConfig(AppConfig): name = "auditlog_tests" + + def ready(self) -> None: + from auditlog_tests.test_registry import ( + create_only_auditlog, + update_only_auditlog, + ) + + create_only_auditlog.register_from_settings() + update_only_auditlog.register_from_settings() diff --git a/auditlog_tests/models.py b/auditlog_tests/models.py index 1e16f933..72d85f55 100644 --- a/auditlog_tests/models.py +++ b/auditlog_tests/models.py @@ -330,6 +330,22 @@ class AutoManyRelatedModel(models.Model): related = models.ManyToManyField(SimpleModel) +class CreateUpdateOnlyModel(models.Model): + """ + A simple model to test create_only_auditlog and update_only_auditlog. + """ + + text = models.TextField(blank=True) + boolean = models.BooleanField(default=False) + integer = models.IntegerField(blank=True, null=True) + datetime = models.DateTimeField(auto_now=True) + + history = AuditlogHistoryField(delete_related=True) + + def __str__(self): + return self.text + + auditlog.register(AltPrimaryKeyModel) auditlog.register(UUIDPrimaryKeyModel) auditlog.register(ProxyModel) diff --git a/auditlog_tests/test_registry.py b/auditlog_tests/test_registry.py new file mode 100644 index 00000000..5d64459f --- /dev/null +++ b/auditlog_tests/test_registry.py @@ -0,0 +1,16 @@ +from auditlog.registry import AuditlogModelRegistry + +create_only_auditlog = AuditlogModelRegistry( + create=True, + update=False, + delete=False, + access=False, + m2m=False, +) +update_only_auditlog = AuditlogModelRegistry( + create=False, + update=True, + delete=False, + access=False, + m2m=False, +) diff --git a/auditlog_tests/tests.py b/auditlog_tests/tests.py index 1ce319f0..85b6a38e 100644 --- a/auditlog_tests/tests.py +++ b/auditlog_tests/tests.py @@ -39,6 +39,7 @@ AutoManyRelatedModel, CharfieldTextfieldModel, ChoicesFieldModel, + CreateUpdateOnlyModel, DateTimeFieldModel, JSONModel, ManyRelatedModel, @@ -59,6 +60,7 @@ SimpleNonManagedModel, UUIDPrimaryKeyModel, ) +from auditlog_tests.test_registry import create_only_auditlog, update_only_auditlog class SimpleModelTest(TestCase): @@ -2643,3 +2645,76 @@ 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 CustomAuditlogTest(TestCase): + def setUp(self): + super().setUp() + + def tearDown(self): + for model in create_only_auditlog.get_models(): + create_only_auditlog.unregister(model) + for model in update_only_auditlog.get_models(): + update_only_auditlog.unregister(model) + + def make_object(self): + return CreateUpdateOnlyModel.objects.create(text="I am not difficult.") + + def update_object(self, obj): + obj.text = "Changed" + obj.save() + return obj + + def test_create_only_auditlog(self): + with override_settings( + AUDITLOG_REGISTRY=[ + "auditlog_tests.test_registry.create_only_auditlog", + ] + ): + create_only_auditlog.register( + CreateUpdateOnlyModel, include_fields=["text"] + ) + + # Get the object to work with + obj = self.make_object() + + # Check for log entries + self.assertEqual(obj.history.count(), 1, msg="There is one log entry") + + history = obj.history.get() + self.check_create_log_entry(obj, history) + + updated_obj = self.update_object(obj) + self.check_no_update_entry(updated_obj) + + def test_update_only_auditlog(self): + with override_settings( + AUDITLOG_REGISTRY=[ + "auditlog_tests.test_registry.update_only_auditlog", + ] + ): + update_only_auditlog.register( + CreateUpdateOnlyModel, include_fields=["text"] + ) + + # Get the object to work with + obj = self.make_object() + self.assertEqual(obj.history.all().count(), 0) + updated_obj = self.update_object(obj) + self.assertEqual(updated_obj.history.all().count(), 1) + history = updated_obj.history.get() + self.assertEqual( + history.action, + LogEntry.Action.UPDATE, + ) + + def check_create_log_entry(self, obj, history): + self.assertEqual( + history.action, LogEntry.Action.CREATE, msg="Action is 'CREATE'" + ) + self.assertEqual(history.object_repr, str(obj), msg="Representation is equal") + + def check_no_update_entry(self, obj): + self.assertEqual( + obj.history.all().filter(action=LogEntry.Action.UPDATE).count(), 0 + ) diff --git a/docs/source/usage.rst b/docs/source/usage.rst index 8cabfbff..1e07765e 100644 --- a/docs/source/usage.rst +++ b/docs/source/usage.rst @@ -289,6 +289,17 @@ If the value is `None`, the default getter will be used. .. versionadded:: 3.0.0 +**AUDITLOG_REGISTRY** +A list or tuple of AuditlogModelRegistry instances. +This settings allow to configure multiple auditlogs with conditionally disabled logging per action +or to have custom signal receivers. + +If not specified, this setting defaults to: +.. code-block:: python + AUDITLOG_REGISTRY = ["auditlog.registry.auditlog"] + +.. versionadded:: 3.0.0 + Actors ------