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.
This commit is contained in:
David Thompson 2025-10-19 00:55:43 +13:00 committed by GitHub
parent d417f30142
commit b1b6f9f4dd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 230 additions and 11 deletions

View file

@ -4,6 +4,7 @@
#### Improvements #### 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)) - Drop 'Python 3.9' support ([#773](https://github.com/jazzband/django-auditlog/pull/773))
## 3.3.0 (2025-09-18) ## 3.3.0 (2025-09-18)

View file

@ -62,3 +62,8 @@ settings.AUDITLOG_STORE_JSON_CHANGES = getattr(
) )
settings.AUDITLOG_MASK_CALLABLE = getattr(settings, "AUDITLOG_MASK_CALLABLE", None) 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
)

View file

@ -554,7 +554,9 @@ class LogEntry(models.Model):
return value return value
# Attempt to return the string representation of the object # Attempt to return the string representation of the object
try: 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. # ObjectDoesNotExist will be raised if the object was deleted.
except ObjectDoesNotExist: except ObjectDoesNotExist:
return f"Deleted '{field.related_model.__name__}' ({value})" 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: if settings.AUDITLOG_USE_TEXT_CHANGES_IF_JSON_IS_NOT_PRESENT:
return json_then_text return json_then_text
return default 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

View file

@ -4,7 +4,7 @@ from django.conf import settings
from auditlog.context import auditlog_disabled from auditlog.context import auditlog_disabled
from auditlog.diff import model_instance_diff 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 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: if not instance._state.adding and instance.pk is not None:
update_fields = kwargs.get("update_fields", 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( _create_log_entry(
action=LogEntry.Action.UPDATE, action=LogEntry.Action.UPDATE,
instance=instance, instance=instance,
@ -170,12 +170,12 @@ def make_log_m2m_changes(field_name):
if action not in ["post_add", "post_clear", "post_remove"]: if action not in ["post_add", "post_clear", "post_remove"]:
return return
model_manager = _get_manager_from_settings(kwargs["model"])
if action == "post_clear": if action == "post_clear":
changed_queryset = kwargs["model"]._default_manager.all() changed_queryset = model_manager.all()
else: else:
changed_queryset = kwargs["model"]._default_manager.filter( changed_queryset = model_manager.filter(pk__in=kwargs["pk_set"])
pk__in=kwargs["pk_set"]
)
if action in ["post_add"]: if action in ["post_add"]:
LogEntry.objects.log_m2m_changes( LogEntry.objects.log_m2m_changes(

0
auditlog_tests/auditlog Normal file
View file

View file

@ -430,6 +430,40 @@ class SwappedManagerModel(models.Model):
objects = SecretManager() 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): class AutoManyRelatedModel(models.Model):
related = models.ManyToManyField(SimpleModel) 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(ManyRelatedModel, m2m_fields={"related"})
m2m_only_auditlog.register(ModelForReusableThroughModel, m2m_fields={"related"}) m2m_only_auditlog.register(ModelForReusableThroughModel, m2m_fields={"related"})
m2m_only_auditlog.register(OtherModelForReusableThroughModel, 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(SimpleExcludeModel, exclude_fields=["text"])
auditlog.register(SimpleMappingModel, mapping_fields={"sku": "Product No."}) auditlog.register(SimpleMappingModel, mapping_fields={"sku": "Product No."})
auditlog.register(AdditionalDataIncludedModel) auditlog.register(AdditionalDataIncludedModel)

View file

@ -46,6 +46,8 @@ from test_app.models import (
ProxyModel, ProxyModel,
RelatedModel, RelatedModel,
ReusableThroughRelatedModel, ReusableThroughRelatedModel,
SecretM2MModel,
SecretRelatedModel,
SerializeNaturalKeyRelatedModel, SerializeNaturalKeyRelatedModel,
SerializeOnlySomeOfThisModel, SerializeOnlySomeOfThisModel,
SerializePrimaryKeyRelatedModel, SerializePrimaryKeyRelatedModel,
@ -1358,7 +1360,7 @@ class RegisterModelSettingsTest(TestCase):
self.assertTrue(self.test_auditlog.contains(SimpleExcludeModel)) self.assertTrue(self.test_auditlog.contains(SimpleExcludeModel))
self.assertTrue(self.test_auditlog.contains(ChoicesFieldModel)) 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): def test_register_models_register_model_with_attrs(self):
self.test_auditlog._register_models( self.test_auditlog._register_models(
@ -2931,6 +2933,136 @@ class ModelManagerTest(TestCase):
self.assertEqual(log.changes_dict["name"], ["Public", "Updated"]) 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): class TestMaskStr(TestCase):
"""Test the mask_str function that masks sensitive data.""" """Test the mask_str function that masks sensitive data."""

View file

@ -141,7 +141,7 @@ For example, to use a custom masking function::
# In your_app/utils.py # In your_app/utils.py
def custom_mask(value: str) -> str: def custom_mask(value: str) -> str:
return "****" + value[-4:] # Only show last 4 characters return "****" + value[-4:] # Only show last 4 characters
# In your models.py # In your models.py
auditlog.register( auditlog.register(
MyModel, 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 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 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. in plain text but need to be auditable.
When a masked field changes, its value will be replaced with a masked 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. 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 .. code-block:: python
@ -377,6 +377,36 @@ This means that primitives such as booleans, integers, etc. will be represented
.. versionadded:: 3.2.0 .. versionadded:: 3.2.0
**AUDITLOG_USE_BASE_MANAGER**
This configuration variable determines whether to use `base managers
<https://docs.djangoproject.com/en/dev/topics/db/managers/#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 Actors
------ ------