Coverage for core/models/change_logging.py: 75%
92 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 18:35 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 18:35 +0000
1from functools import cached_property
3from django.conf import settings
4from django.contrib.contenttypes.fields import GenericForeignKey
5from django.core.exceptions import ValidationError
6from django.db import models
7from django.urls import reverse
8from django.utils.translation import gettext_lazy as _
10from core.choices import ObjectChangeActionChoices
11from core.querysets import ObjectChangeQuerySet
12from netbox.models.features import ChangeLoggingMixin, has_feature
13from utilities.data import deep_compare_dict
15__all__ = (
16 'ObjectChange',
17)
20class ObjectChange(models.Model):
21 """
22 Record a change to an object and the user account associated with that change. A change record may optionally
23 indicate an object related to the one being changed. For example, a change to an interface may also indicate the
24 parent device. This will ensure changes made to component models appear in the parent model's changelog.
25 """
26 time = models.DateTimeField(
27 verbose_name=_('time'),
28 auto_now_add=True,
29 editable=False,
30 db_index=True
31 )
32 user = models.ForeignKey(
33 to=settings.AUTH_USER_MODEL,
34 on_delete=models.SET_NULL,
35 related_name='changes',
36 blank=True,
37 null=True
38 )
39 user_name = models.CharField(
40 verbose_name=_('user name'),
41 max_length=150,
42 editable=False
43 )
44 request_id = models.UUIDField(
45 verbose_name=_('request ID'),
46 editable=False,
47 db_index=True
48 )
49 action = models.CharField(
50 verbose_name=_('action'),
51 max_length=50,
52 choices=ObjectChangeActionChoices
53 )
54 changed_object_type = models.ForeignKey(
55 to='contenttypes.ContentType',
56 on_delete=models.PROTECT,
57 related_name='+'
58 )
59 changed_object_id = models.PositiveBigIntegerField()
60 changed_object = GenericForeignKey(
61 ct_field='changed_object_type',
62 fk_field='changed_object_id'
63 )
64 related_object_type = models.ForeignKey(
65 to='contenttypes.ContentType',
66 on_delete=models.PROTECT,
67 related_name='+',
68 blank=True,
69 null=True
70 )
71 related_object_id = models.PositiveBigIntegerField(
72 blank=True,
73 null=True
74 )
75 related_object = GenericForeignKey(
76 ct_field='related_object_type',
77 fk_field='related_object_id'
78 )
79 object_repr = models.CharField(
80 max_length=200,
81 editable=False
82 )
83 message = models.CharField(
84 verbose_name=_('message'),
85 max_length=200,
86 editable=False,
87 blank=True
88 )
89 prechange_data = models.JSONField(
90 verbose_name=_('pre-change data'),
91 editable=False,
92 blank=True,
93 null=True
94 )
95 postchange_data = models.JSONField(
96 verbose_name=_('post-change data'),
97 editable=False,
98 blank=True,
99 null=True
100 )
102 objects = ObjectChangeQuerySet.as_manager()
104 class Meta:
105 ordering = ['-time']
106 indexes = (
107 models.Index(fields=('changed_object_type', 'changed_object_id')),
108 models.Index(fields=('related_object_type', 'related_object_id')),
109 )
110 verbose_name = _('object change')
111 verbose_name_plural = _('object changes')
113 def __str__(self):
114 return '{} {} {} by {}'.format(
115 self.changed_object_type,
116 self.object_repr,
117 self.get_action_display().lower(),
118 self.user_name
119 )
121 def clean(self):
122 super().clean()
124 # Validate the assigned object type
125 if not has_feature(self.changed_object_type, 'change_logging'):
126 raise ValidationError(
127 _("Change logging is not supported for this object type ({type}).").format(
128 type=self.changed_object_type
129 )
130 )
132 def save(self, *args, **kwargs):
134 # Record the user's name and the object's representation as static strings
135 if not self.user_name: 135 ↛ 137line 135 didn't jump to line 137 because the condition on line 135 was always true
136 self.user_name = self.user.username
137 if not self.object_repr: 137 ↛ 138line 137 didn't jump to line 138 because the condition on line 137 was never true
138 self.object_repr = str(self.changed_object)
140 return super().save(*args, **kwargs)
142 def get_absolute_url(self):
143 return reverse('core:objectchange', args=[self.pk])
145 def get_action_color(self):
146 return ObjectChangeActionChoices.colors.get(self.action)
148 @cached_property
149 def has_changes(self):
150 return self.prechange_data != self.postchange_data
152 @cached_property
153 def diff_exclude_fields(self):
154 """
155 Return a set of attributes which should be ignored when calculating a diff
156 between the pre- and post-change data. (For instance, it would not make
157 sense to compare the "last updated" times as these are expected to differ.)
158 """
159 model = self.changed_object_type.model_class()
160 attrs = set()
162 # model_class() returns None when the model's app is no longer installed
163 # (e.g. a removed plugin); there are no fields to exclude, and the
164 # issubclass() checks below would raise TypeError on None.
165 if model is None: 165 ↛ 166line 165 didn't jump to line 166 because the condition on line 165 was never true
166 return attrs
168 # Exclude auto-populated change tracking fields
169 if issubclass(model, ChangeLoggingMixin): 169 ↛ 173line 169 didn't jump to line 173 because the condition on line 169 was always true
170 attrs.update({'created', 'last_updated'})
172 # Exclude trigger-maintained ltree columns (path and the optional sort_path)
173 from netbox.models.ltree import LtreeModel
174 if issubclass(model, LtreeModel):
175 attrs.update({'path', 'sort_path'})
177 # Exclude MPTT bookkeeping columns for the deprecated MPTT-backed
178 # NestedGroupModel still shipped for plugin compatibility.
179 from mptt.models import MPTTModel
180 if issubclass(model, MPTTModel): 180 ↛ 181line 180 didn't jump to line 181 because the condition on line 180 was never true
181 attrs.update({'lft', 'rght', 'tree_id', 'level'})
183 return attrs
185 def get_clean_data(self, prefix):
186 """
187 Return only the pre-/post-change attributes which are relevant for calculating a diff.
188 """
189 ret = {}
190 change_data = getattr(self, f'{prefix}_data') or {}
191 for k, v in change_data.items():
192 if k not in self.diff_exclude_fields and not k.startswith('_'):
193 ret[k] = v
194 return ret
196 @cached_property
197 def prechange_data_clean(self):
198 return self.get_clean_data('prechange')
200 @cached_property
201 def postchange_data_clean(self):
202 return self.get_clean_data('postchange')
204 def diff(self):
205 """
206 Return a dictionary of pre- and post-change values for attributes which have changed.
207 """
208 prechange_data = self.prechange_data_clean
209 postchange_data = self.postchange_data_clean
211 # Determine which attributes have changed
212 if self.action == ObjectChangeActionChoices.ACTION_CREATE:
213 changed_attrs = sorted(postchange_data.keys())
214 return {
215 'pre': {k: prechange_data.get(k) for k in changed_attrs},
216 'post': {k: postchange_data.get(k) for k in changed_attrs},
217 }
218 if self.action == ObjectChangeActionChoices.ACTION_DELETE:
219 changed_attrs = sorted(prechange_data.keys())
220 return {
221 'pre': {k: prechange_data.get(k) for k in changed_attrs},
222 'post': {k: postchange_data.get(k) for k in changed_attrs},
223 }
224 diff_added, diff_removed = deep_compare_dict(prechange_data, postchange_data)
225 return {
226 'pre': dict(sorted(diff_removed.items())),
227 'post': dict(sorted(diff_added.items())),
228 }