diff --git a/docs/configuration/miscellaneous.md b/docs/configuration/miscellaneous.md index f143be139..300b1ba01 100644 --- a/docs/configuration/miscellaneous.md +++ b/docs/configuration/miscellaneous.md @@ -80,6 +80,17 @@ changes in the database indefinitely. --- +## CHANGELOG_SKIP_EMPTY_CHANGES + +Default: False + +Enables skipping the creation of logged changes on updates if there were no modifications to the object. + +!!! note + As a side-effect of turning this on, the `last_updated` field will not be included in the change log record. + +--- + ## DATA_UPLOAD_MAX_MEMORY_SIZE Default: `2621440` (2.5 MB) diff --git a/netbox/circuits/models/circuits.py b/netbox/circuits/models/circuits.py index 4dc775364..0e224c6d0 100644 --- a/netbox/circuits/models/circuits.py +++ b/netbox/circuits/models/circuits.py @@ -239,7 +239,8 @@ class CircuitTermination( raise ValidationError("A circuit termination cannot attach to both a site and a provider network.") def to_objectchange(self, action): - objectchange = super().to_objectchange(action) + if (objectchange := super().to_objectchange(action)) is None: + return None objectchange.related_object = self.circuit return objectchange diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index 90bf9501f..4048a091a 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -386,7 +386,8 @@ class CableTermination(ChangeLoggedModel): cache_related_objects.alters_data = True def to_objectchange(self, action): - objectchange = super().to_objectchange(action) + if (objectchange := super().to_objectchange(action)) is None: + return None objectchange.related_object = self.termination return objectchange diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py index dacd7ec3e..b56a55aed 100644 --- a/netbox/dcim/models/device_component_templates.py +++ b/netbox/dcim/models/device_component_templates.py @@ -91,7 +91,8 @@ class ComponentTemplateModel(ChangeLoggedModel, TrackingModelMixin): self._original_device_type = self.__dict__.get('device_type_id') def to_objectchange(self, action): - objectchange = super().to_objectchange(action) + if (objectchange := super().to_objectchange(action)) is None: + return None objectchange.related_object = self.device_type return objectchange @@ -138,7 +139,8 @@ class ModularComponentTemplateModel(ComponentTemplateModel): ) def to_objectchange(self, action): - objectchange = super().to_objectchange(action) + if (objectchange := super().to_objectchange(action)) is None: + return None if self.device_type is not None: objectchange.related_object = self.device_type elif self.module_type is not None: diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index ef235078f..39a98a302 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -93,7 +93,8 @@ class ComponentModel(NetBoxModel): return self.name def to_objectchange(self, action): - objectchange = super().to_objectchange(action) + if (objectchange := super().to_objectchange(action)) is None: + return None objectchange.related_object = self.device return objectchange diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index d49536c58..bcbfb1aa9 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -688,7 +688,8 @@ class ImageAttachment(ChangeLoggedModel): return None def to_objectchange(self, action): - objectchange = super().to_objectchange(action) + if (objectchange := super().to_objectchange(action)) is None: + return None objectchange.related_object = self.parent return objectchange diff --git a/netbox/extras/signals.py b/netbox/extras/signals.py index 42204f86e..2f463dd5c 100644 --- a/netbox/extras/signals.py +++ b/netbox/extras/signals.py @@ -80,9 +80,10 @@ def handle_changed_object(sender, instance, **kwargs): ) else: objectchange = instance.to_objectchange(action) - objectchange.user = request.user - objectchange.request_id = request.id - objectchange.save() + if objectchange: + objectchange.user = request.user + objectchange.request_id = request.id + objectchange.save() # If this is an M2M change, update the previously queued webhook (from post_save) queue = events_queue.get() diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index adf130ad7..d0d0fe475 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -902,7 +902,8 @@ class IPAddress(PrimaryModel): return attrs def to_objectchange(self, action): - objectchange = super().to_objectchange(action) + if (objectchange := super().to_objectchange(action)) is None: + return None objectchange.related_object = self.assigned_object return objectchange diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py index 8b0b477dc..3c4806c1d 100644 --- a/netbox/netbox/models/features.py +++ b/netbox/netbox/models/features.py @@ -15,6 +15,7 @@ from core.choices import JobStatusChoices from core.models import ContentType from extras.choices import * from extras.utils import is_taggable, register_features +from netbox.config import get_config from netbox.registry import registry from netbox.signals import post_clean from utilities.json import CustomFieldJSONEncoder @@ -84,6 +85,15 @@ class ChangeLoggingMixin(models.Model): by ChangeLoggingMiddleware. """ from extras.models import ObjectChange + + postchange_data = None + if action in (ObjectChangeActionChoices.ACTION_CREATE, ObjectChangeActionChoices.ACTION_UPDATE): + postchange_data = self.serialize_object() + + if get_config().CHANGELOG_SKIP_EMPTY_CHANGES and action == ObjectChangeActionChoices.ACTION_UPDATE and hasattr(self, '_prechange_snapshot'): + if postchange_data == self._prechange_snapshot: + return None + objectchange = ObjectChange( changed_object=self, object_repr=str(self)[:200], @@ -92,7 +102,7 @@ class ChangeLoggingMixin(models.Model): if hasattr(self, '_prechange_snapshot'): objectchange.prechange_data = self._prechange_snapshot if action in (ObjectChangeActionChoices.ACTION_CREATE, ObjectChangeActionChoices.ACTION_UPDATE): - objectchange.postchange_data = self.serialize_object() + objectchange.postchange_data = postchange_data return objectchange diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index e2cf1cd8c..fd18f26c1 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -177,6 +177,7 @@ STORAGE_CONFIG = getattr(configuration, 'STORAGE_CONFIG', {}) TIME_FORMAT = getattr(configuration, 'TIME_FORMAT', 'g:i a') TIME_ZONE = getattr(configuration, 'TIME_ZONE', 'UTC') ENABLE_LOCALIZATION = getattr(configuration, 'ENABLE_LOCALIZATION', False) +CHANGELOG_SKIP_EMPTY_CHANGES = getattr(configuration, 'CHANGELOG_SKIP_EMPTY_CHANGES', False) # Check for hard-coded dynamic config parameters for param in PARAMS: diff --git a/netbox/tenancy/models/contacts.py b/netbox/tenancy/models/contacts.py index 81e11a7dd..55f267dea 100644 --- a/netbox/tenancy/models/contacts.py +++ b/netbox/tenancy/models/contacts.py @@ -171,6 +171,7 @@ class ContactAssignment(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, Chan ) def to_objectchange(self, action): - objectchange = super().to_objectchange(action) + if (objectchange := super().to_objectchange(action)) is None: + return None objectchange.related_object = self.object return objectchange diff --git a/netbox/utilities/utils.py b/netbox/utilities/utils.py index 2d11810fc..f7ad8f708 100644 --- a/netbox/utilities/utils.py +++ b/netbox/utilities/utils.py @@ -159,6 +159,9 @@ def serialize_object(obj, resolve_tags=True, extra=None): for field in ['level', 'lft', 'rght', 'tree_id']: data.pop(field) + if get_config().CHANGELOG_SKIP_EMPTY_CHANGES and 'last_updated' in data: + data.pop('last_updated') + # Include custom_field_data as "custom_fields" if hasattr(obj, 'custom_field_data'): data['custom_fields'] = data.pop('custom_field_data') diff --git a/netbox/virtualization/models/virtualmachines.py b/netbox/virtualization/models/virtualmachines.py index 233d51d63..f01ad6014 100644 --- a/netbox/virtualization/models/virtualmachines.py +++ b/netbox/virtualization/models/virtualmachines.py @@ -298,7 +298,8 @@ class ComponentModel(NetBoxModel): return self.name def to_objectchange(self, action): - objectchange = super().to_objectchange(action) + if (objectchange := super().to_objectchange(action)) is None: + return None objectchange.related_object = self.virtual_machine return objectchange diff --git a/netbox/vpn/models/tunnels.py b/netbox/vpn/models/tunnels.py index be1e40142..dbe17e2a8 100644 --- a/netbox/vpn/models/tunnels.py +++ b/netbox/vpn/models/tunnels.py @@ -178,6 +178,7 @@ class TunnelTermination(CustomFieldsMixin, CustomLinksMixin, TagsMixin, ChangeLo }) def to_objectchange(self, action): - objectchange = super().to_objectchange(action) + if (objectchange := super().to_objectchange(action)) is None: + return None objectchange.related_object = self.tunnel return objectchange