From b1b6f9f4ddb3c21d6301227dbcc78b06a010c131 Mon Sep 17 00:00:00 2001 From: David Thompson Date: Sun, 19 Oct 2025 00:55:43 +1300 Subject: [PATCH] Add base manager setting to override default manager use (#747) (#766) * Add `AUDITLOG_USE_BASE_MANAGER` setting configuration * Adjust `LogEntry._get_changes_display_for_fk_field` behaviour for setting * Adjust `log_update` and `log_m2m_changes` in `receivers.py` for setting * Add `ModelManagerTest.test_use_base_manager_setting` * Add entry in Usage documentation * (In passing, fix a formatting issue in `usage.rst`) The `AUDITLOG_USE_BASE_MANAGER` setting has a default of `False` to maintain initial backwards compatibility with previous versions. --- CHANGELOG.md | 1 + auditlog/conf.py | 5 ++ auditlog/models.py | 17 +++- auditlog/receivers.py | 12 +-- auditlog_tests/auditlog | 0 auditlog_tests/test_app/models.py | 36 ++++++++ auditlog_tests/tests.py | 134 +++++++++++++++++++++++++++++- docs/source/usage.rst | 36 +++++++- 8 files changed, 230 insertions(+), 11 deletions(-) create mode 100644 auditlog_tests/auditlog diff --git a/CHANGELOG.md b/CHANGELOG.md index c32e105..fead883 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ #### Improvements +- Add `AUDITLOG_USE_BASE_MANAGER` setting to override default manager use ([#766](https://github.com/jazzband/django-auditlog/pull/766)) - Drop 'Python 3.9' support ([#773](https://github.com/jazzband/django-auditlog/pull/773)) ## 3.3.0 (2025-09-18) diff --git a/auditlog/conf.py b/auditlog/conf.py index b151b24..a2895b8 100644 --- a/auditlog/conf.py +++ b/auditlog/conf.py @@ -62,3 +62,8 @@ settings.AUDITLOG_STORE_JSON_CHANGES = getattr( ) settings.AUDITLOG_MASK_CALLABLE = getattr(settings, "AUDITLOG_MASK_CALLABLE", None) + +# Use base model managers instead of default model managers +settings.AUDITLOG_USE_BASE_MANAGER = getattr( + settings, "AUDITLOG_USE_BASE_MANAGER", False +) diff --git a/auditlog/models.py b/auditlog/models.py index d73ecda..bfde86f 100644 --- a/auditlog/models.py +++ b/auditlog/models.py @@ -554,7 +554,9 @@ class LogEntry(models.Model): return value # Attempt to return the string representation of the object try: - return smart_str(field.related_model._default_manager.get(pk=pk_value)) + related_model_manager = _get_manager_from_settings(field.related_model) + + return smart_str(related_model_manager.get(pk=pk_value)) # ObjectDoesNotExist will be raised if the object was deleted. except ObjectDoesNotExist: return f"Deleted '{field.related_model.__name__}' ({value})" @@ -623,3 +625,16 @@ def _changes_func() -> Callable[[LogEntry], dict]: if settings.AUDITLOG_USE_TEXT_CHANGES_IF_JSON_IS_NOT_PRESENT: return json_then_text return default + + +def _get_manager_from_settings(model: type[models.Model]) -> models.Manager: + """ + Get model manager as selected by AUDITLOG_USE_BASE_MANAGER. + + - True: return model._meta.base_manager + - False: return model._meta.default_manager + """ + if settings.AUDITLOG_USE_BASE_MANAGER: + return model._meta.base_manager + else: + return model._meta.default_manager diff --git a/auditlog/receivers.py b/auditlog/receivers.py index 525675a..7c38d3c 100644 --- a/auditlog/receivers.py +++ b/auditlog/receivers.py @@ -4,7 +4,7 @@ from django.conf import settings from auditlog.context import auditlog_disabled from auditlog.diff import model_instance_diff -from auditlog.models import LogEntry +from auditlog.models import LogEntry, _get_manager_from_settings from auditlog.signals import post_log, pre_log @@ -56,7 +56,7 @@ def log_update(sender, instance, **kwargs): """ if not instance._state.adding and instance.pk is not None: update_fields = kwargs.get("update_fields", None) - old = sender._default_manager.filter(pk=instance.pk).first() + old = _get_manager_from_settings(sender).filter(pk=instance.pk).first() _create_log_entry( action=LogEntry.Action.UPDATE, instance=instance, @@ -170,12 +170,12 @@ def make_log_m2m_changes(field_name): if action not in ["post_add", "post_clear", "post_remove"]: return + model_manager = _get_manager_from_settings(kwargs["model"]) + if action == "post_clear": - changed_queryset = kwargs["model"]._default_manager.all() + changed_queryset = model_manager.all() else: - changed_queryset = kwargs["model"]._default_manager.filter( - pk__in=kwargs["pk_set"] - ) + changed_queryset = model_manager.filter(pk__in=kwargs["pk_set"]) if action in ["post_add"]: LogEntry.objects.log_m2m_changes( diff --git a/auditlog_tests/auditlog b/auditlog_tests/auditlog new file mode 100644 index 0000000..e69de29 diff --git a/auditlog_tests/test_app/models.py b/auditlog_tests/test_app/models.py index dd240fc..e996a25 100644 --- a/auditlog_tests/test_app/models.py +++ b/auditlog_tests/test_app/models.py @@ -430,6 +430,40 @@ class SwappedManagerModel(models.Model): objects = SecretManager() + def __str__(self): + return str(self.name) + + +@auditlog.register() +class SecretRelatedModel(RelatedModelParent): + """ + A RelatedModel, but with a foreign key to an object that could be secret. + """ + + related = models.ForeignKey( + "SwappedManagerModel", related_name="related_models", on_delete=models.CASCADE + ) + one_to_one = models.OneToOneField( + to="SwappedManagerModel", + on_delete=models.CASCADE, + related_name="reverse_one_to_one", + ) + + history = AuditlogHistoryField(delete_related=True) + + def __str__(self): + return f"SecretRelatedModel #{self.pk} -> {self.related.id}" + + +class SecretM2MModel(models.Model): + m2m_related = models.ManyToManyField( + "SwappedManagerModel", related_name="m2m_related" + ) + name = models.CharField(max_length=255) + + def __str__(self): + return str(self.name) + class AutoManyRelatedModel(models.Model): related = models.ManyToManyField(SimpleModel) @@ -459,6 +493,8 @@ auditlog.register(ManyRelatedModel.recursive.through) m2m_only_auditlog.register(ManyRelatedModel, m2m_fields={"related"}) m2m_only_auditlog.register(ModelForReusableThroughModel, m2m_fields={"related"}) m2m_only_auditlog.register(OtherModelForReusableThroughModel, m2m_fields={"related"}) +m2m_only_auditlog.register(SecretM2MModel, m2m_fields={"m2m_related"}) +m2m_only_auditlog.register(SwappedManagerModel, m2m_fields={"m2m_related"}) auditlog.register(SimpleExcludeModel, exclude_fields=["text"]) auditlog.register(SimpleMappingModel, mapping_fields={"sku": "Product No."}) auditlog.register(AdditionalDataIncludedModel) diff --git a/auditlog_tests/tests.py b/auditlog_tests/tests.py index 7c2e1cc..fb1f434 100644 --- a/auditlog_tests/tests.py +++ b/auditlog_tests/tests.py @@ -46,6 +46,8 @@ from test_app.models import ( ProxyModel, RelatedModel, ReusableThroughRelatedModel, + SecretM2MModel, + SecretRelatedModel, SerializeNaturalKeyRelatedModel, SerializeOnlySomeOfThisModel, SerializePrimaryKeyRelatedModel, @@ -1358,7 +1360,7 @@ class RegisterModelSettingsTest(TestCase): self.assertTrue(self.test_auditlog.contains(SimpleExcludeModel)) self.assertTrue(self.test_auditlog.contains(ChoicesFieldModel)) - self.assertEqual(len(self.test_auditlog.get_models()), 34) + self.assertEqual(len(self.test_auditlog.get_models()), 36) def test_register_models_register_model_with_attrs(self): self.test_auditlog._register_models( @@ -2931,6 +2933,136 @@ class ModelManagerTest(TestCase): self.assertEqual(log.changes_dict["name"], ["Public", "Updated"]) +class BaseManagerSettingTest(TestCase): + """ + If the AUDITLOG_USE_BASE_MANAGER setting is enabled, "secret" objects + should be audited as if they were public, with full access to field + values. + """ + + def test_use_base_manager_setting_update(self): + """ + Model update. The default False case is covered by test_update_secret. + """ + secret = SwappedManagerModel.objects.create(is_secret=True, name="Secret") + with override_settings(AUDITLOG_USE_BASE_MANAGER=True): + secret.name = "Updated" + secret.save() + log = LogEntry.objects.get_for_object(secret).first() + self.assertEqual(log.action, LogEntry.Action.UPDATE) + self.assertEqual(log.changes_dict["name"], ["Secret", "Updated"]) + + def test_use_base_manager_setting_related_model(self): + """ + When AUDITLOG_USE_BASE_MANAGER is enabled, related model changes that + are normally invisible to the default model manager should remain + visible and not refer to "deleted" objects. + """ + t1 = datetime.datetime(2025, 1, 1, 12, tzinfo=datetime.timezone.utc) + with ( + override_settings(AUDITLOG_USE_BASE_MANAGER=False), + freezegun.freeze_time(t1), + ): + public_one = SwappedManagerModel.objects.create(name="Public One") + secret_one = SwappedManagerModel.objects.create( + is_secret=True, name="Secret One" + ) + instance_one = SecretRelatedModel.objects.create( + one_to_one=public_one, + related=secret_one, + ) + + log_one = instance_one.history.filter(timestamp=t1).first() + self.assertIsInstance(log_one, LogEntry) + display_dict = log_one.changes_display_dict + self.assertEqual(display_dict["related"][0], "None") + self.assertEqual( + display_dict["related"][1], + f"Deleted 'SwappedManagerModel' ({secret_one.id})", + "Default manager should have no visibility of secret object", + ) + self.assertEqual(display_dict["one to one"][0], "None") + self.assertEqual(display_dict["one to one"][1], "Public One") + + t2 = t1 + datetime.timedelta(days=20) + with ( + override_settings(AUDITLOG_USE_BASE_MANAGER=True), + freezegun.freeze_time(t2), + ): + public_two = SwappedManagerModel.objects.create(name="Public Two") + secret_two = SwappedManagerModel.objects.create( + is_secret=True, name="Secret Two" + ) + instance_two = SecretRelatedModel.objects.create( + one_to_one=public_two, + related=secret_two, + ) + + log_two = instance_two.history.filter(timestamp=t2).first() + self.assertIsInstance(log_two, LogEntry) + display_dict = log_two.changes_display_dict + self.assertEqual(display_dict["related"][0], "None") + self.assertEqual( + display_dict["related"][1], + "Secret Two", + "Base manager should have full visibility of secret object", + ) + self.assertEqual(display_dict["one to one"][0], "None") + self.assertEqual(display_dict["one to one"][1], "Public Two") + + def test_use_base_manager_setting_changes(self): + """ + When AUDITLOG_USE_BASE_MANAGER is enabled, registered many-to-many model + changes that refer to an object hidden from the default model manager + should remain visible and be logged. + """ + with override_settings(AUDITLOG_USE_BASE_MANAGER=False): + obj_one = SwappedManagerModel.objects.create( + is_secret=True, name="Secret One" + ) + m2m_one = SecretM2MModel.objects.create(name="M2M One") + m2m_one.m2m_related.add(obj_one) + + self.assertIn(m2m_one, obj_one.m2m_related.all(), "Secret One sees M2M One") + self.assertNotIn( + obj_one, m2m_one.m2m_related.all(), "M2M One cannot see Secret One" + ) + self.assertEqual( + 0, + LogEntry.objects.get_for_object(m2m_one).count(), + "No update with default manager", + ) + + with override_settings(AUDITLOG_USE_BASE_MANAGER=True): + obj_two = SwappedManagerModel.objects.create( + is_secret=True, name="Secret Two" + ) + m2m_two = SecretM2MModel.objects.create(name="M2M Two") + m2m_two.m2m_related.add(obj_two) + + self.assertIn(m2m_two, obj_two.m2m_related.all(), "Secret Two sees M2M Two") + self.assertNotIn( + obj_two, m2m_two.m2m_related.all(), "M2M Two cannot see Secret Two" + ) + self.assertEqual( + 1, + LogEntry.objects.get_for_object(m2m_two).count(), + "Update logged with base manager", + ) + + log_entry = LogEntry.objects.get_for_object(m2m_two).first() + self.assertEqual( + log_entry.changes, + { + "m2m_related": { + "type": "m2m", + "operation": "add", + "objects": [smart_str(obj_two)], + } + }, + ) + + class TestMaskStr(TestCase): """Test the mask_str function that masks sensitive data.""" diff --git a/docs/source/usage.rst b/docs/source/usage.rst index 72acfa1..bc65d8a 100644 --- a/docs/source/usage.rst +++ b/docs/source/usage.rst @@ -141,7 +141,7 @@ For example, to use a custom masking function:: # In your_app/utils.py def custom_mask(value: str) -> str: return "****" + value[-4:] # Only show last 4 characters - + # In your models.py auditlog.register( MyModel, @@ -270,13 +270,13 @@ It will be considered when ``AUDITLOG_DISABLE_REMOTE_ADDR`` is `True`. You can use this setting to mask specific field values in all tracked models while still logging changes. This is useful when models contain sensitive fields -like `password`, `api_key`, or `secret_token`` that should not be logged +like `password`, `api_key`, or `secret_token` that should not be logged in plain text but need to be auditable. When a masked field changes, its value will be replaced with a masked representation (e.g., `****`) in the audit log instead of storing the actual value. -This setting will be applied only when `AUDITLOG_INCLUDE_ALL_MODELS`` is `True`. +This setting will be applied only when ``AUDITLOG_INCLUDE_ALL_MODELS`` is `True`. .. code-block:: python @@ -377,6 +377,36 @@ This means that primitives such as booleans, integers, etc. will be represented .. versionadded:: 3.2.0 +**AUDITLOG_USE_BASE_MANAGER** + +This configuration variable determines whether to use `base managers +`_ for +tracked models instead of their default managers. + +This setting can be useful for applications where the default manager behaviour +hides some objects from the majority of ORM queries: + +.. code-block:: python + + class SecretManager(models.Manager): + def get_queryset(self): + return super().get_queryset().filter(is_secret=False) + + + @auditlog.register() + class SwappedManagerModel(models.Model): + is_secret = models.BooleanField(default=False) + name = models.CharField(max_length=255) + + objects = SecretManager() + +In this example, when ``AUDITLOG_USE_BASE_MANAGER`` is set to `True`, objects +with the `is_secret` field set will be made visible to Auditlog. Otherwise you +may see inaccurate data in log entries, recording changes to a seemingly +"non-existent" object with empty fields. + +.. versionadded:: 3.4.0 + Actors ------