This commit is contained in:
Thiago Chaves de Oliveira Horta 2026-04-28 17:27:16 +03:00 committed by GitHub
commit 288170c748
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 145 additions and 11 deletions

View file

@ -58,10 +58,7 @@ class LogEntryManager(models.Manager):
"content_type", ContentType.objects.get_for_model(instance)
)
kwargs.setdefault("object_pk", pk)
try:
object_repr = smart_str(instance)
except ObjectDoesNotExist:
object_repr = DEFAULT_OBJECT_REPR
object_repr = _get_object_repr_value(instance)
kwargs.setdefault("object_repr", object_repr)
kwargs.setdefault(
"serialized_data", self._get_serialized_data_or_none(instance)
@ -104,10 +101,7 @@ class LogEntryManager(models.Manager):
"content_type", ContentType.objects.get_for_model(instance)
)
kwargs.setdefault("object_pk", pk)
try:
object_repr = smart_str(instance)
except ObjectDoesNotExist:
object_repr = DEFAULT_OBJECT_REPR
object_repr = _get_object_repr_value(instance)
kwargs.setdefault("object_repr", object_repr)
kwargs.setdefault("action", LogEntry.Action.UPDATE)
@ -118,7 +112,9 @@ class LogEntryManager(models.Manager):
if callable(get_additional_data):
kwargs.setdefault("additional_data", get_additional_data())
objects = [smart_str(instance) for instance in changed_queryset]
objects = [
_get_object_repr_value(instance) for instance in changed_queryset
]
kwargs["changes"] = {
field_name: {
"type": "m2m",
@ -566,7 +562,7 @@ class AbstractLogEntry(models.Model):
try:
related_model_manager = _get_manager_from_settings(field.related_model)
return smart_str(related_model_manager.get(pk=pk_value))
return _get_object_repr_value(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})"
@ -653,3 +649,28 @@ def _get_manager_from_settings(model: type[models.Model]) -> models.Manager:
return model._meta.base_manager
else:
return model._meta.default_manager
def _get_object_repr_value(instance):
"""
Get the object_repr value of the given model instance. The default behavior is to stringify the
instance, unless an explicit object_repr_field is given when registering the model onto the audit log.
:param instance: The model instance to get the object_repr value for.
:type instance: Model
:return: The object_repr value of the given model instance.
"""
from auditlog.registry import auditlog
target = instance
try:
if auditlog.contains(instance.__class__):
model_fields = auditlog.get_model_fields(instance.__class__)
object_repr_field = model_fields["object_repr_field"]
if object_repr_field:
target = getattr(instance, object_repr_field, DEFAULT_OBJECT_REPR)
if callable(target):
target = target()
return smart_str(target)
except ObjectDoesNotExist:
return DEFAULT_OBJECT_REPR

View file

@ -68,6 +68,7 @@ class AuditlogModelRegistry:
mask_fields: list[str] | None = None,
mask_callable: str | None = None,
m2m_fields: Collection[str] | None = None,
object_repr_field: str | None = None,
serialize_data: bool = False,
serialize_kwargs: dict[str, Any] | None = None,
serialize_auditlog_fields_only: bool = False,
@ -82,6 +83,8 @@ class AuditlogModelRegistry:
:param mask_fields: The fields to mask for sensitive info.
:param mask_callable: The dotted path to a callable that will be used for masking. If not provided,
the default mask_callable will be used.
:param object_repr_field: The field or method to represent the instance in the log entry's object_repr
field, when absent the instance is stringified
:param m2m_fields: The fields to handle as many to many.
:param serialize_data: Option to include a dictionary of the objects state in the auditlog.
:param serialize_kwargs: Optional kwargs to pass to Django serializer
@ -125,6 +128,7 @@ class AuditlogModelRegistry:
"mask_fields": mask_fields,
"mask_callable": mask_callable,
"m2m_fields": m2m_fields,
"object_repr_field": object_repr_field,
"serialize_data": serialize_data,
"serialize_kwargs": serialize_kwargs,
"serialize_auditlog_fields_only": serialize_auditlog_fields_only,
@ -177,6 +181,7 @@ class AuditlogModelRegistry:
"mapping_fields": dict(self._registry[model]["mapping_fields"]),
"mask_fields": list(self._registry[model]["mask_fields"]),
"mask_callable": self._registry[model]["mask_callable"],
"object_repr_field": self._registry[model]["object_repr_field"],
}
def get_serialize_options(self, model: ModelBase):

View file

@ -483,6 +483,20 @@ class NullableFieldModel(models.Model):
history = AuditlogHistoryField(delete_related=True)
class ObjectReprOverrideModel(models.Model):
public_field = models.CharField(max_length=255)
sensitive_field = models.CharField(max_length=255)
related = models.ManyToManyField("self", blank=True)
parent = models.ForeignKey("self", on_delete=models.SET_NULL, null=True, blank=True)
history = AuditlogHistoryField()
def some_callable(self):
return f"{self.sensitive_field[0:3]}..."
def __str__(self):
return self.sensitive_field
auditlog.register(AltPrimaryKeyModel)
auditlog.register(UUIDPrimaryKeyModel)
auditlog.register(ModelPrimaryKeyModel)

View file

@ -45,6 +45,7 @@ from test_app.models import (
ModelPrimaryKeyModel,
NoDeleteHistoryModel,
NullableJSONModel,
ObjectReprOverrideModel,
ProxyModel,
RelatedModel,
RelatedModelParent,
@ -417,6 +418,87 @@ class ModelPrimaryKeyTest(TransactionTestCase):
self.assertEqual(pk, key.pk)
class BaseObjectReprOverrideTests:
object_repr_field = None
def get_expected_repr(self, instance):
raise NotImplementedError
def setUp(self):
auditlog.register(
ObjectReprOverrideModel,
object_repr_field=self.object_repr_field,
m2m_fields=["related"],
)
self.obj = ObjectReprOverrideModel.objects.create(
public_field="public information",
sensitive_field="secret information",
)
def tearDown(self):
auditlog.unregister(ObjectReprOverrideModel)
def test_object_repr_uses_override(self):
self.assertEqual(
self.obj.history.latest().object_repr,
self.get_expected_repr(self.obj),
msg=f"Failed overriding object_repr with config: {self.object_repr_field}",
)
def test_m2m_changes_dict_uses_object_repr_field(self):
second = ObjectReprOverrideModel.objects.create(
public_field="second node", sensitive_field="second secret"
)
self.obj.related.add(second)
log_entry = self.obj.history.latest()
self.assertEqual(
log_entry.changes["related"]["objects"],
[self.get_expected_repr(second)],
msg=f"Failed M2M objects array with config: {self.object_repr_field}",
)
def test_fk_changes_display_dict_uses_object_repr_field(self):
second = ObjectReprOverrideModel.objects.create(
public_field="second node", sensitive_field="second secret"
)
second.parent = self.obj
second.save()
log_entry = second.history.latest()
self.assertEqual(
log_entry.changes_display_dict["parent"][1],
self.get_expected_repr(self.obj),
msg=f"Failed FK changes display dict with config: {self.object_repr_field}",
)
class NoObjectReprOverrideTest(BaseObjectReprOverrideTests, TestCase):
object_repr_field = None
def get_expected_repr(self, instance):
return smart_str(instance)
class ObjectReprFromPublicFieldTest(BaseObjectReprOverrideTests, TestCase):
object_repr_field = "public_field"
def get_expected_repr(self, instance):
return instance.public_field
class ObjectReprFromCallableTest(BaseObjectReprOverrideTests, TestCase):
object_repr_field = "some_callable"
def get_expected_repr(self, instance):
return instance.some_callable()
class ObjectReprFromMissingFieldTest(BaseObjectReprOverrideTests, TestCase):
object_repr_field = "bogus_missing_field"
def get_expected_repr(self, instance):
return DEFAULT_OBJECT_REPR
class ProxyModelBase(SimpleModelTest):
def make_object(self):
return ProxyModel.objects.create(text="I am not what you think.")
@ -1399,7 +1481,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()), 36)
self.assertEqual(len(self.test_auditlog.get_models()), 37)
def test_register_models_register_model_with_attrs(self):
self.test_auditlog._register_models(

View file

@ -167,6 +167,18 @@ the first half of the string with asterisks.
Masking fields
**Overriding object_repr**
Some models may leak sensitive information to the audit logs through their builtin ``__str__`` method.
You can customize how the entries in the audit log summarize the objects by setting the ``object_repr_field`` when you register the model::
auditlog.register(MyModel, object_repr_field="safe_summary")
Alternatively you can set the field to a method::
auditlog.register(MyModel, object_repr_field="get_safe_summary")
**Many-to-many fields**
Changes to many-to-many fields are not tracked by default. If you want to enable tracking of a many-to-many field on a model, pass ``m2m_fields`` to the ``register`` method: