change_logging.py 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216
  1. from functools import cached_property
  2. from django.conf import settings
  3. from django.contrib.contenttypes.fields import GenericForeignKey
  4. from django.core.exceptions import ValidationError
  5. from django.db import models
  6. from django.urls import reverse
  7. from django.utils.translation import gettext_lazy as _
  8. from mptt.models import MPTTModel
  9. from core.choices import ObjectChangeActionChoices
  10. from core.querysets import ObjectChangeQuerySet
  11. from netbox.models.features import ChangeLoggingMixin, has_feature
  12. from utilities.data import shallow_compare_dict
  13. __all__ = (
  14. 'ObjectChange',
  15. )
  16. class ObjectChange(models.Model):
  17. """
  18. Record a change to an object and the user account associated with that change. A change record may optionally
  19. indicate an object related to the one being changed. For example, a change to an interface may also indicate the
  20. parent device. This will ensure changes made to component models appear in the parent model's changelog.
  21. """
  22. time = models.DateTimeField(
  23. verbose_name=_('time'),
  24. auto_now_add=True,
  25. editable=False,
  26. db_index=True
  27. )
  28. user = models.ForeignKey(
  29. to=settings.AUTH_USER_MODEL,
  30. on_delete=models.SET_NULL,
  31. related_name='changes',
  32. blank=True,
  33. null=True
  34. )
  35. user_name = models.CharField(
  36. verbose_name=_('user name'),
  37. max_length=150,
  38. editable=False
  39. )
  40. request_id = models.UUIDField(
  41. verbose_name=_('request ID'),
  42. editable=False,
  43. db_index=True
  44. )
  45. action = models.CharField(
  46. verbose_name=_('action'),
  47. max_length=50,
  48. choices=ObjectChangeActionChoices
  49. )
  50. changed_object_type = models.ForeignKey(
  51. to='contenttypes.ContentType',
  52. on_delete=models.PROTECT,
  53. related_name='+'
  54. )
  55. changed_object_id = models.PositiveBigIntegerField()
  56. changed_object = GenericForeignKey(
  57. ct_field='changed_object_type',
  58. fk_field='changed_object_id'
  59. )
  60. related_object_type = models.ForeignKey(
  61. to='contenttypes.ContentType',
  62. on_delete=models.PROTECT,
  63. related_name='+',
  64. blank=True,
  65. null=True
  66. )
  67. related_object_id = models.PositiveBigIntegerField(
  68. blank=True,
  69. null=True
  70. )
  71. related_object = GenericForeignKey(
  72. ct_field='related_object_type',
  73. fk_field='related_object_id'
  74. )
  75. object_repr = models.CharField(
  76. max_length=200,
  77. editable=False
  78. )
  79. message = models.CharField(
  80. verbose_name=_('message'),
  81. max_length=200,
  82. editable=False,
  83. blank=True
  84. )
  85. prechange_data = models.JSONField(
  86. verbose_name=_('pre-change data'),
  87. editable=False,
  88. blank=True,
  89. null=True
  90. )
  91. postchange_data = models.JSONField(
  92. verbose_name=_('post-change data'),
  93. editable=False,
  94. blank=True,
  95. null=True
  96. )
  97. objects = ObjectChangeQuerySet.as_manager()
  98. class Meta:
  99. ordering = ['-time']
  100. indexes = (
  101. models.Index(fields=('changed_object_type', 'changed_object_id')),
  102. models.Index(fields=('related_object_type', 'related_object_id')),
  103. )
  104. verbose_name = _('object change')
  105. verbose_name_plural = _('object changes')
  106. def __str__(self):
  107. return '{} {} {} by {}'.format(
  108. self.changed_object_type,
  109. self.object_repr,
  110. self.get_action_display().lower(),
  111. self.user_name
  112. )
  113. def clean(self):
  114. super().clean()
  115. # Validate the assigned object type
  116. if not has_feature(self.changed_object_type, 'change_logging'):
  117. raise ValidationError(
  118. _("Change logging is not supported for this object type ({type}).").format(
  119. type=self.changed_object_type
  120. )
  121. )
  122. def save(self, *args, **kwargs):
  123. # Record the user's name and the object's representation as static strings
  124. if not self.user_name:
  125. self.user_name = self.user.username
  126. if not self.object_repr:
  127. self.object_repr = str(self.changed_object)
  128. return super().save(*args, **kwargs)
  129. def get_absolute_url(self):
  130. return reverse('core:objectchange', args=[self.pk])
  131. def get_action_color(self):
  132. return ObjectChangeActionChoices.colors.get(self.action)
  133. @cached_property
  134. def has_changes(self):
  135. return self.prechange_data != self.postchange_data
  136. @cached_property
  137. def diff_exclude_fields(self):
  138. """
  139. Return a set of attributes which should be ignored when calculating a diff
  140. between the pre- and post-change data. (For instance, it would not make
  141. sense to compare the "last updated" times as these are expected to differ.)
  142. """
  143. model = self.changed_object_type.model_class()
  144. attrs = set()
  145. # Exclude auto-populated change tracking fields
  146. if issubclass(model, ChangeLoggingMixin):
  147. attrs.update({'created', 'last_updated'})
  148. # Exclude MPTT-internal fields
  149. if issubclass(model, MPTTModel):
  150. attrs.update({'level', 'lft', 'rght', 'tree_id'})
  151. return attrs
  152. def get_clean_data(self, prefix):
  153. """
  154. Return only the pre-/post-change attributes which are relevant for calculating a diff.
  155. """
  156. ret = {}
  157. change_data = getattr(self, f'{prefix}_data') or {}
  158. for k, v in change_data.items():
  159. if k not in self.diff_exclude_fields and not k.startswith('_'):
  160. ret[k] = v
  161. return ret
  162. @cached_property
  163. def prechange_data_clean(self):
  164. return self.get_clean_data('prechange')
  165. @cached_property
  166. def postchange_data_clean(self):
  167. return self.get_clean_data('postchange')
  168. def diff(self):
  169. """
  170. Return a dictionary of pre- and post-change values for attributes which have changed.
  171. """
  172. prechange_data = self.prechange_data_clean
  173. postchange_data = self.postchange_data_clean
  174. # Determine which attributes have changed
  175. if self.action == ObjectChangeActionChoices.ACTION_CREATE:
  176. changed_attrs = sorted(postchange_data.keys())
  177. elif self.action == ObjectChangeActionChoices.ACTION_DELETE:
  178. changed_attrs = sorted(prechange_data.keys())
  179. else:
  180. # TODO: Support deep (recursive) comparison
  181. changed_data = shallow_compare_dict(prechange_data, postchange_data)
  182. changed_attrs = sorted(changed_data.keys())
  183. return {
  184. 'pre': {
  185. k: prechange_data.get(k) for k in changed_attrs
  186. },
  187. 'post': {
  188. k: postchange_data.get(k) for k in changed_attrs
  189. },
  190. }