mirror of
https://github.com/jazzband/django-auditlog.git
synced 2026-03-16 22:20:26 +00:00
* 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:
parent
d417f30142
commit
b1b6f9f4dd
8 changed files with 230 additions and 11 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
0
auditlog_tests/auditlog
Normal file
0
auditlog_tests/auditlog
Normal file
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
<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
|
||||
------
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue