mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-25 18:08:38 -06:00
Closes #16290: Capture entire object in changelog data
This commit is contained in:
parent
418389c577
commit
a094719d23
@ -30,6 +30,16 @@ class ObjectChangeSerializer(BaseModelSerializer):
|
|||||||
changed_object = serializers.SerializerMethodField(
|
changed_object = serializers.SerializerMethodField(
|
||||||
read_only=True
|
read_only=True
|
||||||
)
|
)
|
||||||
|
prechange_data = serializers.JSONField(
|
||||||
|
source='prechange_data_clean',
|
||||||
|
read_only=True,
|
||||||
|
allow_null=True
|
||||||
|
)
|
||||||
|
postchange_data = serializers.JSONField(
|
||||||
|
source='postchange_data_clean',
|
||||||
|
read_only=True,
|
||||||
|
allow_null=True
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = ObjectChange
|
model = ObjectChange
|
||||||
|
@ -1,12 +1,17 @@
|
|||||||
|
from functools import cached_property
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from mptt.models import MPTTModel
|
||||||
|
|
||||||
from core.models import ObjectType
|
from core.models import ObjectType
|
||||||
from extras.choices import *
|
from extras.choices import *
|
||||||
|
from netbox.models.features import ChangeLoggingMixin
|
||||||
|
from utilities.data import shallow_compare_dict
|
||||||
from ..querysets import ObjectChangeQuerySet
|
from ..querysets import ObjectChangeQuerySet
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
@ -136,6 +141,71 @@ class ObjectChange(models.Model):
|
|||||||
def get_action_color(self):
|
def get_action_color(self):
|
||||||
return ObjectChangeActionChoices.colors.get(self.action)
|
return ObjectChangeActionChoices.colors.get(self.action)
|
||||||
|
|
||||||
@property
|
@cached_property
|
||||||
def has_changes(self):
|
def has_changes(self):
|
||||||
return self.prechange_data != self.postchange_data
|
return self.prechange_data != self.postchange_data
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def diff_exclude_fields(self):
|
||||||
|
"""
|
||||||
|
Return a set of attributes which should be ignored when calculating a diff
|
||||||
|
between the pre- and post-change data. (For instance, it would not make
|
||||||
|
sense to compare the "last updated" times as these are expected to differ.)
|
||||||
|
"""
|
||||||
|
model = self.changed_object_type.model_class()
|
||||||
|
attrs = set()
|
||||||
|
|
||||||
|
# Exclude auto-populated change tracking fields
|
||||||
|
if issubclass(model, ChangeLoggingMixin):
|
||||||
|
attrs.update({'created', 'last_updated'})
|
||||||
|
|
||||||
|
# Exclude MPTT-internal fields
|
||||||
|
if issubclass(model, MPTTModel):
|
||||||
|
attrs.update({'level', 'lft', 'rght', 'tree_id'})
|
||||||
|
|
||||||
|
return attrs
|
||||||
|
|
||||||
|
def get_clean_data(self, prefix):
|
||||||
|
"""
|
||||||
|
Return only the pre-/post-change attributes which are relevant for calculating a diff.
|
||||||
|
"""
|
||||||
|
ret = {}
|
||||||
|
change_data = getattr(self, f'{prefix}_data') or {}
|
||||||
|
for k, v in change_data.items():
|
||||||
|
if k not in self.diff_exclude_fields and not k.startswith('_'):
|
||||||
|
ret[k] = v
|
||||||
|
return ret
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def prechange_data_clean(self):
|
||||||
|
return self.get_clean_data('prechange')
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def postchange_data_clean(self):
|
||||||
|
return self.get_clean_data('postchange')
|
||||||
|
|
||||||
|
def diff(self):
|
||||||
|
"""
|
||||||
|
Return a dictionary of pre- and post-change values for attributes which have changed.
|
||||||
|
"""
|
||||||
|
prechange_data = self.prechange_data_clean
|
||||||
|
postchange_data = self.postchange_data_clean
|
||||||
|
|
||||||
|
# Determine which attributes have changed
|
||||||
|
if self.action == ObjectChangeActionChoices.ACTION_CREATE:
|
||||||
|
changed_attrs = sorted(postchange_data.keys())
|
||||||
|
elif self.action == ObjectChangeActionChoices.ACTION_DELETE:
|
||||||
|
changed_attrs = sorted(prechange_data.keys())
|
||||||
|
else:
|
||||||
|
# TODO: Support deep (recursive) comparison
|
||||||
|
changed_data = shallow_compare_dict(prechange_data, postchange_data)
|
||||||
|
changed_attrs = sorted(changed_data.keys())
|
||||||
|
|
||||||
|
return {
|
||||||
|
'pre': {
|
||||||
|
k: prechange_data.get(k) for k in changed_attrs
|
||||||
|
},
|
||||||
|
'post': {
|
||||||
|
k: postchange_data.get(k) for k in changed_attrs
|
||||||
|
},
|
||||||
|
}
|
||||||
|
@ -75,6 +75,10 @@ class ChangeLogViewTest(ModelViewTestCase):
|
|||||||
self.assertEqual(oc.postchange_data['custom_fields']['cf2'], form_data['cf_cf2'])
|
self.assertEqual(oc.postchange_data['custom_fields']['cf2'], form_data['cf_cf2'])
|
||||||
self.assertEqual(oc.postchange_data['tags'], ['Tag 1', 'Tag 2'])
|
self.assertEqual(oc.postchange_data['tags'], ['Tag 1', 'Tag 2'])
|
||||||
|
|
||||||
|
# Check that private attributes were included in raw data but not display data
|
||||||
|
self.assertIn('_name', oc.postchange_data)
|
||||||
|
self.assertNotIn('_name', oc.postchange_data_clean)
|
||||||
|
|
||||||
def test_update_object(self):
|
def test_update_object(self):
|
||||||
site = Site(name='Site 1', slug='site-1')
|
site = Site(name='Site 1', slug='site-1')
|
||||||
site.save()
|
site.save()
|
||||||
@ -112,6 +116,12 @@ class ChangeLogViewTest(ModelViewTestCase):
|
|||||||
self.assertEqual(oc.postchange_data['custom_fields']['cf2'], form_data['cf_cf2'])
|
self.assertEqual(oc.postchange_data['custom_fields']['cf2'], form_data['cf_cf2'])
|
||||||
self.assertEqual(oc.postchange_data['tags'], ['Tag 3'])
|
self.assertEqual(oc.postchange_data['tags'], ['Tag 3'])
|
||||||
|
|
||||||
|
# Check that private attributes were included in raw data but not display data
|
||||||
|
self.assertIn('_name', oc.prechange_data)
|
||||||
|
self.assertNotIn('_name', oc.prechange_data_clean)
|
||||||
|
self.assertIn('_name', oc.postchange_data)
|
||||||
|
self.assertNotIn('_name', oc.postchange_data_clean)
|
||||||
|
|
||||||
def test_delete_object(self):
|
def test_delete_object(self):
|
||||||
site = Site(
|
site = Site(
|
||||||
name='Site 1',
|
name='Site 1',
|
||||||
@ -142,6 +152,10 @@ class ChangeLogViewTest(ModelViewTestCase):
|
|||||||
self.assertEqual(oc.prechange_data['tags'], ['Tag 1', 'Tag 2'])
|
self.assertEqual(oc.prechange_data['tags'], ['Tag 1', 'Tag 2'])
|
||||||
self.assertEqual(oc.postchange_data, None)
|
self.assertEqual(oc.postchange_data, None)
|
||||||
|
|
||||||
|
# Check that private attributes were included in raw data but not display data
|
||||||
|
self.assertIn('_name', oc.prechange_data)
|
||||||
|
self.assertNotIn('_name', oc.prechange_data_clean)
|
||||||
|
|
||||||
def test_bulk_update_objects(self):
|
def test_bulk_update_objects(self):
|
||||||
sites = (
|
sites = (
|
||||||
Site(name='Site 1', slug='site-1', status=SiteStatusChoices.STATUS_ACTIVE),
|
Site(name='Site 1', slug='site-1', status=SiteStatusChoices.STATUS_ACTIVE),
|
||||||
@ -338,6 +352,10 @@ class ChangeLogAPITest(APITestCase):
|
|||||||
self.assertEqual(oc.postchange_data['custom_fields'], data['custom_fields'])
|
self.assertEqual(oc.postchange_data['custom_fields'], data['custom_fields'])
|
||||||
self.assertEqual(oc.postchange_data['tags'], ['Tag 1', 'Tag 2'])
|
self.assertEqual(oc.postchange_data['tags'], ['Tag 1', 'Tag 2'])
|
||||||
|
|
||||||
|
# Check that private attributes were included in raw data but not display data
|
||||||
|
self.assertIn('_name', oc.postchange_data)
|
||||||
|
self.assertNotIn('_name', oc.postchange_data_clean)
|
||||||
|
|
||||||
def test_update_object(self):
|
def test_update_object(self):
|
||||||
site = Site(name='Site 1', slug='site-1')
|
site = Site(name='Site 1', slug='site-1')
|
||||||
site.save()
|
site.save()
|
||||||
@ -370,6 +388,12 @@ class ChangeLogAPITest(APITestCase):
|
|||||||
self.assertEqual(oc.postchange_data['custom_fields'], data['custom_fields'])
|
self.assertEqual(oc.postchange_data['custom_fields'], data['custom_fields'])
|
||||||
self.assertEqual(oc.postchange_data['tags'], ['Tag 3'])
|
self.assertEqual(oc.postchange_data['tags'], ['Tag 3'])
|
||||||
|
|
||||||
|
# Check that private attributes were included in raw data but not display data
|
||||||
|
self.assertIn('_name', oc.prechange_data)
|
||||||
|
self.assertNotIn('_name', oc.prechange_data_clean)
|
||||||
|
self.assertIn('_name', oc.postchange_data)
|
||||||
|
self.assertNotIn('_name', oc.postchange_data_clean)
|
||||||
|
|
||||||
def test_delete_object(self):
|
def test_delete_object(self):
|
||||||
site = Site(
|
site = Site(
|
||||||
name='Site 1',
|
name='Site 1',
|
||||||
@ -398,6 +422,10 @@ class ChangeLogAPITest(APITestCase):
|
|||||||
self.assertEqual(oc.prechange_data['tags'], ['Tag 1', 'Tag 2'])
|
self.assertEqual(oc.prechange_data['tags'], ['Tag 1', 'Tag 2'])
|
||||||
self.assertEqual(oc.postchange_data, None)
|
self.assertEqual(oc.postchange_data, None)
|
||||||
|
|
||||||
|
# Check that private attributes were included in raw data but not display data
|
||||||
|
self.assertIn('_name', oc.prechange_data)
|
||||||
|
self.assertNotIn('_name', oc.prechange_data_clean)
|
||||||
|
|
||||||
def test_bulk_create_objects(self):
|
def test_bulk_create_objects(self):
|
||||||
data = (
|
data = (
|
||||||
{
|
{
|
||||||
|
@ -723,15 +723,15 @@ class ObjectChangeView(generic.ObjectView):
|
|||||||
|
|
||||||
if not instance.prechange_data and instance.action in ['update', 'delete'] and prev_change:
|
if not instance.prechange_data and instance.action in ['update', 'delete'] and prev_change:
|
||||||
non_atomic_change = True
|
non_atomic_change = True
|
||||||
prechange_data = prev_change.postchange_data
|
prechange_data = prev_change.postchange_data_clean
|
||||||
else:
|
else:
|
||||||
non_atomic_change = False
|
non_atomic_change = False
|
||||||
prechange_data = instance.prechange_data
|
prechange_data = instance.prechange_data_clean
|
||||||
|
|
||||||
if prechange_data and instance.postchange_data:
|
if prechange_data and instance.postchange_data:
|
||||||
diff_added = shallow_compare_dict(
|
diff_added = shallow_compare_dict(
|
||||||
prechange_data or dict(),
|
prechange_data or dict(),
|
||||||
instance.postchange_data or dict(),
|
instance.postchange_data_clean or dict(),
|
||||||
exclude=['last_updated'],
|
exclude=['last_updated'],
|
||||||
)
|
)
|
||||||
diff_removed = {
|
diff_removed = {
|
||||||
|
BIN
netbox/project-static/dist/netbox.css
vendored
BIN
netbox/project-static/dist/netbox.css
vendored
Binary file not shown.
@ -1,7 +1,7 @@
|
|||||||
// Serialized data from change records
|
// Serialized data from change records
|
||||||
pre.change-data {
|
pre.change-data {
|
||||||
padding-right: 0;
|
border-radius: 0;
|
||||||
padding-left: 0;
|
padding: 0;
|
||||||
|
|
||||||
// Display each line individually for highlighting
|
// Display each line individually for highlighting
|
||||||
> span {
|
> span {
|
||||||
|
@ -112,7 +112,7 @@
|
|||||||
{% if object.prechange_data %}
|
{% if object.prechange_data %}
|
||||||
{% spaceless %}
|
{% spaceless %}
|
||||||
<pre class="change-data">
|
<pre class="change-data">
|
||||||
{% for k, v in object.prechange_data.items %}
|
{% for k, v in object.prechange_data_clean.items %}
|
||||||
<span{% if k in diff_removed %} class="removed"{% endif %}>{{ k }}: {{ v|json }}</span>
|
<span{% if k in diff_removed %} class="removed"{% endif %}>{{ k }}: {{ v|json }}</span>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</pre>
|
</pre>
|
||||||
@ -132,7 +132,7 @@
|
|||||||
{% if object.postchange_data %}
|
{% if object.postchange_data %}
|
||||||
{% spaceless %}
|
{% spaceless %}
|
||||||
<pre class="change-data">
|
<pre class="change-data">
|
||||||
{% for k, v in object.postchange_data.items %}
|
{% for k, v in object.postchange_data_clean.items %}
|
||||||
<span{% if k in diff_added %} class="added"{% endif %}>{{ k }}: {{ v|json }}</span>
|
<span{% if k in diff_added %} class="added"{% endif %}>{{ k }}: {{ v|json }}</span>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</pre>
|
</pre>
|
||||||
|
@ -2,7 +2,6 @@ import json
|
|||||||
|
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.core import serializers
|
from django.core import serializers
|
||||||
from mptt.models import MPTTModel
|
|
||||||
|
|
||||||
from extras.utils import is_taggable
|
from extras.utils import is_taggable
|
||||||
|
|
||||||
@ -16,8 +15,7 @@ def serialize_object(obj, resolve_tags=True, extra=None, exclude=None):
|
|||||||
"""
|
"""
|
||||||
Return a generic JSON representation of an object using Django's built-in serializer. (This is used for things like
|
Return a generic JSON representation of an object using Django's built-in serializer. (This is used for things like
|
||||||
change logging, not the REST API.) Optionally include a dictionary to supplement the object data. A list of keys
|
change logging, not the REST API.) Optionally include a dictionary to supplement the object data. A list of keys
|
||||||
can be provided to exclude them from the returned dictionary. Private fields (prefaced with an underscore) are
|
can be provided to exclude them from the returned dictionary.
|
||||||
implicitly excluded.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
obj: The object to serialize
|
obj: The object to serialize
|
||||||
@ -30,11 +28,6 @@ def serialize_object(obj, resolve_tags=True, extra=None, exclude=None):
|
|||||||
data = json.loads(json_str)[0]['fields']
|
data = json.loads(json_str)[0]['fields']
|
||||||
exclude = exclude or []
|
exclude = exclude or []
|
||||||
|
|
||||||
# Exclude any MPTTModel fields
|
|
||||||
if issubclass(obj.__class__, MPTTModel):
|
|
||||||
for field in ['level', 'lft', 'rght', 'tree_id']:
|
|
||||||
data.pop(field)
|
|
||||||
|
|
||||||
# Include custom_field_data as "custom_fields"
|
# Include custom_field_data as "custom_fields"
|
||||||
if hasattr(obj, 'custom_field_data'):
|
if hasattr(obj, 'custom_field_data'):
|
||||||
data['custom_fields'] = data.pop('custom_field_data')
|
data['custom_fields'] = data.pop('custom_field_data')
|
||||||
@ -45,9 +38,9 @@ def serialize_object(obj, resolve_tags=True, extra=None, exclude=None):
|
|||||||
tags = getattr(obj, '_tags', None) or obj.tags.all()
|
tags = getattr(obj, '_tags', None) or obj.tags.all()
|
||||||
data['tags'] = sorted([tag.name for tag in tags])
|
data['tags'] = sorted([tag.name for tag in tags])
|
||||||
|
|
||||||
# Skip excluded and private (prefixes with an underscore) attributes
|
# Skip any excluded attributes
|
||||||
for key in list(data.keys()):
|
for key in list(data.keys()):
|
||||||
if key in exclude or (isinstance(key, str) and key.startswith('_')):
|
if key in exclude:
|
||||||
data.pop(key)
|
data.pop(key)
|
||||||
|
|
||||||
# Append any extra data
|
# Append any extra data
|
||||||
|
Loading…
Reference in New Issue
Block a user