diff --git a/CHANGELOG.md b/CHANGELOG.md index ca6a872..d87b91e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ - feat: Add `serialized_data` field on `LogEntry` model. ([#412](https://github.com/jazzband/django-auditlog/pull/412)) - feat: Display the field name as it would be displayed in Django Admin or use `mapping_field` if available [#428](https://github.com/jazzband/django-auditlog/pull/428) +- feat: New context manager `disable_auditlog` to turn off logging and a new setting `AUDITLOG_DISABLE_ON_RAW_SAVE` + to disable it during raw-save operations like loaddata. [#446](https://github.com/jazzband/django-auditlog/pull/446) #### Fixes diff --git a/auditlog/conf.py b/auditlog/conf.py index fdb685c..4df8d87 100644 --- a/auditlog/conf.py +++ b/auditlog/conf.py @@ -15,3 +15,8 @@ settings.AUDITLOG_EXCLUDE_TRACKING_MODELS = getattr( settings.AUDITLOG_INCLUDE_TRACKING_MODELS = getattr( settings, "AUDITLOG_INCLUDE_TRACKING_MODELS", () ) + +# Disable on raw save to avoid logging imports and similar +settings.AUDITLOG_DISABLE_ON_RAW_SAVE = getattr( + settings, "AUDITLOG_DISABLE_ON_RAW_SAVE", False +) diff --git a/auditlog/context.py b/auditlog/context.py index 6e9513d..de2c0ca 100644 --- a/auditlog/context.py +++ b/auditlog/context.py @@ -64,3 +64,15 @@ def _set_actor(user, sender, instance, signal_duid, **kwargs): instance.actor = user instance.remote_addr = auditlog["remote_addr"] + + +@contextlib.contextmanager +def disable_auditlog(): + threadlocal.auditlog_disabled = True + try: + yield + finally: + try: + del threadlocal.auditlog_disabled + except AttributeError: + pass diff --git a/auditlog/receivers.py b/auditlog/receivers.py index b6e867d..8117870 100644 --- a/auditlog/receivers.py +++ b/auditlog/receivers.py @@ -1,9 +1,31 @@ import json +from functools import wraps +from django.conf import settings + +from auditlog.context import threadlocal from auditlog.diff import model_instance_diff from auditlog.models import LogEntry +def check_disable(signal_handler): + """ + Decorator that passes along disabled in kwargs if any of the following is true: + - 'auditlog_disabled' from threadlocal is true + - raw = True and AUDITLOG_DISABLE_ON_RAW_SAVE is True + """ + + @wraps(signal_handler) + def wrapper(*args, **kwargs): + if not getattr(threadlocal, "auditlog_disabled", False) and not ( + kwargs.get("raw") and settings.AUDITLOG_DISABLE_ON_RAW_SAVE + ): + signal_handler(*args, **kwargs) + + return wrapper + + +@check_disable def log_create(sender, instance, created, **kwargs): """ Signal receiver that creates a log entry when a model instance is first saved to the database. @@ -20,6 +42,7 @@ def log_create(sender, instance, created, **kwargs): ) +@check_disable def log_update(sender, instance, **kwargs): """ Signal receiver that creates a log entry when a model instance is changed and saved to the database. @@ -45,6 +68,7 @@ def log_update(sender, instance, **kwargs): ) +@check_disable def log_delete(sender, instance, **kwargs): """ Signal receiver that creates a log entry when a model instance is deleted from the database. @@ -64,6 +88,7 @@ def log_delete(sender, instance, **kwargs): def make_log_m2m_changes(field_name): """Return a handler for m2m_changed with field_name enclosed.""" + @check_disable def log_m2m_changes(signal, action, **kwargs): """Handle m2m_changed and call LogEntry.objects.log_m2m_changes as needed.""" if action not in ["post_add", "post_clear", "post_remove"]: diff --git a/auditlog/registry.py b/auditlog/registry.py index d2ab0b0..3bf3653 100644 --- a/auditlog/registry.py +++ b/auditlog/registry.py @@ -266,7 +266,8 @@ class AuditlogModelRegistry: """ if not isinstance(settings.AUDITLOG_INCLUDE_ALL_MODELS, bool): raise TypeError("Setting 'AUDITLOG_INCLUDE_ALL_MODELS' must be a boolean") - + if not isinstance(settings.AUDITLOG_DISABLE_ON_RAW_SAVE, bool): + raise TypeError("Setting 'AUDITLOG_DISABLE_ON_RAW_SAVE' must be a boolean") if not isinstance(settings.AUDITLOG_EXCLUDE_TRACKING_MODELS, (list, tuple)): raise TypeError( "Setting 'AUDITLOG_EXCLUDE_TRACKING_MODELS' must be a list or tuple" diff --git a/auditlog_tests/fixtures/m2m_test_fixture.json b/auditlog_tests/fixtures/m2m_test_fixture.json new file mode 100644 index 0000000..c5c5d9e --- /dev/null +++ b/auditlog_tests/fixtures/m2m_test_fixture.json @@ -0,0 +1,15 @@ +[ + { + "model": "auditlog_tests.manyrelatedmodel", + "pk": 1, + "fields": { + "recursive": [1], + "related": [1] + } + }, + { + "model": "auditlog_tests.manyrelatedothermodel", + "pk": 1, + "fields": {} + } +] \ No newline at end of file diff --git a/auditlog_tests/tests.py b/auditlog_tests/tests.py index 9205902..b43faaa 100644 --- a/auditlog_tests/tests.py +++ b/auditlog_tests/tests.py @@ -12,12 +12,13 @@ from django.contrib.admin.sites import AdminSite 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.db.models.signals import pre_save from django.test import RequestFactory, TestCase, override_settings from django.utils import dateformat, formats, timezone from auditlog.admin import LogEntryAdmin -from auditlog.context import set_actor +from auditlog.context import disable_auditlog, set_actor from auditlog.diff import model_instance_diff from auditlog.middleware import AuditlogMiddleware from auditlog.models import LogEntry @@ -1092,6 +1093,12 @@ class RegisterModelSettingsTest(TestCase): ): self.test_auditlog.register_from_settings() + with override_settings(AUDITLOG_DISABLE_ON_RAW_SAVE="bad value"): + with self.assertRaisesMessage( + TypeError, "Setting 'AUDITLOG_DISABLE_ON_RAW_SAVE' must be a boolean" + ): + self.test_auditlog.register_from_settings() + @override_settings( AUDITLOG_INCLUDE_ALL_MODELS=True, AUDITLOG_EXCLUDE_TRACKING_MODELS=("auditlog_tests.SimpleExcludeModel",), @@ -1797,3 +1804,54 @@ class TestModelSerialization(TestCase): "value": 11, }, ) + + +@override_settings(AUDITLOG_DISABLE_ON_RAW_SAVE=True) +class DisableTest(TestCase): + """ + All the other tests check logging, so this only needs to test disabled logging. + """ + + def test_create(self): + # Mimic the way imports create objects + inst = SimpleModel( + text="I am a bit more difficult.", boolean=False, datetime=timezone.now() + ) + SimpleModel.save_base(inst, raw=True) + self.assertEqual(0, LogEntry.objects.get_for_object(inst).count()) + + def test_create_with_context_manager(self): + with disable_auditlog(): + inst = SimpleModel.objects.create(text="I am a bit more difficult.") + self.assertEqual(0, LogEntry.objects.get_for_object(inst).count()) + + def test_update(self): + inst = SimpleModel( + text="I am a bit more difficult.", boolean=False, datetime=timezone.now() + ) + SimpleModel.save_base(inst, raw=True) + inst.text = "I feel refreshed" + inst.save_base(raw=True) + self.assertEqual(0, LogEntry.objects.get_for_object(inst).count()) + + def test_update_with_context_manager(self): + inst = SimpleModel( + text="I am a bit more difficult.", boolean=False, datetime=timezone.now() + ) + SimpleModel.save_base(inst, raw=True) + with disable_auditlog(): + inst.text = "I feel refreshed" + inst.save() + self.assertEqual(0, LogEntry.objects.get_for_object(inst).count()) + + def test_m2m(self): + """ + Create m2m from fixture and check that nothing was logged. + This only works with context manager + """ + with disable_auditlog(): + management.call_command("loaddata", "m2m_test_fixture.json", verbosity=0) + recursive = ManyRelatedModel.objects.get(pk=1) + self.assertEqual(0, LogEntry.objects.get_for_object(recursive).count()) + related = ManyRelatedOtherModel.objects.get(pk=1) + self.assertEqual(0, LogEntry.objects.get_for_object(related).count()) diff --git a/docs/source/usage.rst b/docs/source/usage.rst index 00abffd..0675283 100644 --- a/docs/source/usage.rst +++ b/docs/source/usage.rst @@ -201,6 +201,18 @@ It must be a list or tuple. Each item in this setting can be a: .. versionadded:: 2.1.0 +**AUDITLOG_DISABLE_ON_RAW_SAVE** + +Disables logging during raw save. (I.e. for instance using loaddata) + +.. note:: + + M2M operations will still be logged, since they're never considered `raw`. To disable them + you must remove their setting or use the `disable_auditlog` context manager. + +.. versionadded:: 2.2.0 + + Actors ------ @@ -228,10 +240,11 @@ It is recommended to keep all middleware that alters the request loaded before A user as actor. To only have some object changes to be logged with the current request's user as actor manual logging is required. -Context manager -*************** +Context managers +---------------- -.. versionadded:: 2.1.0 +Set actor +********* To enable the automatic logging of the actors outside of request context (e.g. in a Celery task), you can use a context manager:: @@ -244,6 +257,26 @@ manager:: # if your code here leads to creation of LogEntry instances, these will have the actor set ... + +.. versionadded:: 2.1.0 + + +Disable auditlog +**************** + +Disable auditlog temporary, for instance if you need to install a large fixture on a live system or cleanup +corrupt data:: + + from auditlog.context import disable_auditlog + + with disable_auditlog(): + # Do things silently here + ... + + +.. versionadded:: 2.2.0 + + Object history --------------