Skip to content

Commit a094719

Browse files
committed
Closes #16290: Capture entire object in changelog data
1 parent 418389c commit a094719

File tree

8 files changed

+120
-19
lines changed

8 files changed

+120
-19
lines changed

netbox/extras/api/serializers_/change_logging.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,16 @@ class ObjectChangeSerializer(BaseModelSerializer):
3030
changed_object = serializers.SerializerMethodField(
3131
read_only=True
3232
)
33+
prechange_data = serializers.JSONField(
34+
source='prechange_data_clean',
35+
read_only=True,
36+
allow_null=True
37+
)
38+
postchange_data = serializers.JSONField(
39+
source='postchange_data_clean',
40+
read_only=True,
41+
allow_null=True
42+
)
3343

3444
class Meta:
3545
model = ObjectChange

netbox/extras/models/change_logging.py

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
1+
from functools import cached_property
2+
13
from django.conf import settings
24
from django.contrib.contenttypes.fields import GenericForeignKey
35
from django.core.exceptions import ValidationError
46
from django.db import models
57
from django.urls import reverse
68
from django.utils.translation import gettext_lazy as _
9+
from mptt.models import MPTTModel
710

811
from core.models import ObjectType
912
from extras.choices import *
13+
from netbox.models.features import ChangeLoggingMixin
14+
from utilities.data import shallow_compare_dict
1015
from ..querysets import ObjectChangeQuerySet
1116

1217
__all__ = (
@@ -136,6 +141,71 @@ def get_absolute_url(self):
136141
def get_action_color(self):
137142
return ObjectChangeActionChoices.colors.get(self.action)
138143

139-
@property
144+
@cached_property
140145
def has_changes(self):
141146
return self.prechange_data != self.postchange_data
147+
148+
@cached_property
149+
def diff_exclude_fields(self):
150+
"""
151+
Return a set of attributes which should be ignored when calculating a diff
152+
between the pre- and post-change data. (For instance, it would not make
153+
sense to compare the "last updated" times as these are expected to differ.)
154+
"""
155+
model = self.changed_object_type.model_class()
156+
attrs = set()
157+
158+
# Exclude auto-populated change tracking fields
159+
if issubclass(model, ChangeLoggingMixin):
160+
attrs.update({'created', 'last_updated'})
161+
162+
# Exclude MPTT-internal fields
163+
if issubclass(model, MPTTModel):
164+
attrs.update({'level', 'lft', 'rght', 'tree_id'})
165+
166+
return attrs
167+
168+
def get_clean_data(self, prefix):
169+
"""
170+
Return only the pre-/post-change attributes which are relevant for calculating a diff.
171+
"""
172+
ret = {}
173+
change_data = getattr(self, f'{prefix}_data') or {}
174+
for k, v in change_data.items():
175+
if k not in self.diff_exclude_fields and not k.startswith('_'):
176+
ret[k] = v
177+
return ret
178+
179+
@cached_property
180+
def prechange_data_clean(self):
181+
return self.get_clean_data('prechange')
182+
183+
@cached_property
184+
def postchange_data_clean(self):
185+
return self.get_clean_data('postchange')
186+
187+
def diff(self):
188+
"""
189+
Return a dictionary of pre- and post-change values for attributes which have changed.
190+
"""
191+
prechange_data = self.prechange_data_clean
192+
postchange_data = self.postchange_data_clean
193+
194+
# Determine which attributes have changed
195+
if self.action == ObjectChangeActionChoices.ACTION_CREATE:
196+
changed_attrs = sorted(postchange_data.keys())
197+
elif self.action == ObjectChangeActionChoices.ACTION_DELETE:
198+
changed_attrs = sorted(prechange_data.keys())
199+
else:
200+
# TODO: Support deep (recursive) comparison
201+
changed_data = shallow_compare_dict(prechange_data, postchange_data)
202+
changed_attrs = sorted(changed_data.keys())
203+
204+
return {
205+
'pre': {
206+
k: prechange_data.get(k) for k in changed_attrs
207+
},
208+
'post': {
209+
k: postchange_data.get(k) for k in changed_attrs
210+
},
211+
}

netbox/extras/tests/test_changelog.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,10 @@ def test_create_object(self):
7575
self.assertEqual(oc.postchange_data['custom_fields']['cf2'], form_data['cf_cf2'])
7676
self.assertEqual(oc.postchange_data['tags'], ['Tag 1', 'Tag 2'])
7777

78+
# Check that private attributes were included in raw data but not display data
79+
self.assertIn('_name', oc.postchange_data)
80+
self.assertNotIn('_name', oc.postchange_data_clean)
81+
7882
def test_update_object(self):
7983
site = Site(name='Site 1', slug='site-1')
8084
site.save()
@@ -112,6 +116,12 @@ def test_update_object(self):
112116
self.assertEqual(oc.postchange_data['custom_fields']['cf2'], form_data['cf_cf2'])
113117
self.assertEqual(oc.postchange_data['tags'], ['Tag 3'])
114118

119+
# Check that private attributes were included in raw data but not display data
120+
self.assertIn('_name', oc.prechange_data)
121+
self.assertNotIn('_name', oc.prechange_data_clean)
122+
self.assertIn('_name', oc.postchange_data)
123+
self.assertNotIn('_name', oc.postchange_data_clean)
124+
115125
def test_delete_object(self):
116126
site = Site(
117127
name='Site 1',
@@ -142,6 +152,10 @@ def test_delete_object(self):
142152
self.assertEqual(oc.prechange_data['tags'], ['Tag 1', 'Tag 2'])
143153
self.assertEqual(oc.postchange_data, None)
144154

155+
# Check that private attributes were included in raw data but not display data
156+
self.assertIn('_name', oc.prechange_data)
157+
self.assertNotIn('_name', oc.prechange_data_clean)
158+
145159
def test_bulk_update_objects(self):
146160
sites = (
147161
Site(name='Site 1', slug='site-1', status=SiteStatusChoices.STATUS_ACTIVE),
@@ -338,6 +352,10 @@ def test_create_object(self):
338352
self.assertEqual(oc.postchange_data['custom_fields'], data['custom_fields'])
339353
self.assertEqual(oc.postchange_data['tags'], ['Tag 1', 'Tag 2'])
340354

355+
# Check that private attributes were included in raw data but not display data
356+
self.assertIn('_name', oc.postchange_data)
357+
self.assertNotIn('_name', oc.postchange_data_clean)
358+
341359
def test_update_object(self):
342360
site = Site(name='Site 1', slug='site-1')
343361
site.save()
@@ -370,6 +388,12 @@ def test_update_object(self):
370388
self.assertEqual(oc.postchange_data['custom_fields'], data['custom_fields'])
371389
self.assertEqual(oc.postchange_data['tags'], ['Tag 3'])
372390

391+
# Check that private attributes were included in raw data but not display data
392+
self.assertIn('_name', oc.prechange_data)
393+
self.assertNotIn('_name', oc.prechange_data_clean)
394+
self.assertIn('_name', oc.postchange_data)
395+
self.assertNotIn('_name', oc.postchange_data_clean)
396+
373397
def test_delete_object(self):
374398
site = Site(
375399
name='Site 1',
@@ -398,6 +422,10 @@ def test_delete_object(self):
398422
self.assertEqual(oc.prechange_data['tags'], ['Tag 1', 'Tag 2'])
399423
self.assertEqual(oc.postchange_data, None)
400424

425+
# Check that private attributes were included in raw data but not display data
426+
self.assertIn('_name', oc.prechange_data)
427+
self.assertNotIn('_name', oc.prechange_data_clean)
428+
401429
def test_bulk_create_objects(self):
402430
data = (
403431
{

netbox/extras/views.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -723,15 +723,15 @@ def get_extra_context(self, request, instance):
723723

724724
if not instance.prechange_data and instance.action in ['update', 'delete'] and prev_change:
725725
non_atomic_change = True
726-
prechange_data = prev_change.postchange_data
726+
prechange_data = prev_change.postchange_data_clean
727727
else:
728728
non_atomic_change = False
729-
prechange_data = instance.prechange_data
729+
prechange_data = instance.prechange_data_clean
730730

731731
if prechange_data and instance.postchange_data:
732732
diff_added = shallow_compare_dict(
733733
prechange_data or dict(),
734-
instance.postchange_data or dict(),
734+
instance.postchange_data_clean or dict(),
735735
exclude=['last_updated'],
736736
)
737737
diff_removed = {

netbox/project-static/dist/netbox.css

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

netbox/project-static/styles/custom/_code.scss

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Serialized data from change records
22
pre.change-data {
3-
padding-right: 0;
4-
padding-left: 0;
3+
border-radius: 0;
4+
padding: 0;
55

66
// Display each line individually for highlighting
77
> span {

netbox/templates/extras/objectchange.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ <h5 class="card-header">{% trans "Pre-Change Data" %}</h5>
112112
{% if object.prechange_data %}
113113
{% spaceless %}
114114
<pre class="change-data">
115-
{% for k, v in object.prechange_data.items %}
115+
{% for k, v in object.prechange_data_clean.items %}
116116
<span{% if k in diff_removed %} class="removed"{% endif %}>{{ k }}: {{ v|json }}</span>
117117
{% endfor %}
118118
</pre>
@@ -132,7 +132,7 @@ <h5 class="card-header">{% trans "Post-Change Data" %}</h5>
132132
{% if object.postchange_data %}
133133
{% spaceless %}
134134
<pre class="change-data">
135-
{% for k, v in object.postchange_data.items %}
135+
{% for k, v in object.postchange_data_clean.items %}
136136
<span{% if k in diff_added %} class="added"{% endif %}>{{ k }}: {{ v|json }}</span>
137137
{% endfor %}
138138
</pre>

netbox/utilities/serialization.py

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
from django.contrib.contenttypes.models import ContentType
44
from django.core import serializers
5-
from mptt.models import MPTTModel
65

76
from extras.utils import is_taggable
87

@@ -16,8 +15,7 @@ def serialize_object(obj, resolve_tags=True, extra=None, exclude=None):
1615
"""
1716
Return a generic JSON representation of an object using Django's built-in serializer. (This is used for things like
1817
change logging, not the REST API.) Optionally include a dictionary to supplement the object data. A list of keys
19-
can be provided to exclude them from the returned dictionary. Private fields (prefaced with an underscore) are
20-
implicitly excluded.
18+
can be provided to exclude them from the returned dictionary.
2119
2220
Args:
2321
obj: The object to serialize
@@ -30,11 +28,6 @@ def serialize_object(obj, resolve_tags=True, extra=None, exclude=None):
3028
data = json.loads(json_str)[0]['fields']
3129
exclude = exclude or []
3230

33-
# Exclude any MPTTModel fields
34-
if issubclass(obj.__class__, MPTTModel):
35-
for field in ['level', 'lft', 'rght', 'tree_id']:
36-
data.pop(field)
37-
3831
# Include custom_field_data as "custom_fields"
3932
if hasattr(obj, 'custom_field_data'):
4033
data['custom_fields'] = data.pop('custom_field_data')
@@ -45,9 +38,9 @@ def serialize_object(obj, resolve_tags=True, extra=None, exclude=None):
4538
tags = getattr(obj, '_tags', None) or obj.tags.all()
4639
data['tags'] = sorted([tag.name for tag in tags])
4740

48-
# Skip excluded and private (prefixes with an underscore) attributes
41+
# Skip any excluded attributes
4942
for key in list(data.keys()):
50-
if key in exclude or (isinstance(key, str) and key.startswith('_')):
43+
if key in exclude:
5144
data.pop(key)
5245

5346
# Append any extra data

0 commit comments

Comments
 (0)