signals.py 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267
  1. import logging
  2. from threading import local
  3. from django.contrib.contenttypes.models import ContentType
  4. from django.core.exceptions import ValidationError
  5. from django.db.models.fields.reverse_related import ManyToManyRel, ManyToOneRel
  6. from django.db.models.signals import m2m_changed, post_save, pre_delete
  7. from django.dispatch import receiver, Signal
  8. from django.core.signals import request_finished
  9. from django.utils.translation import gettext_lazy as _
  10. from django_prometheus.models import model_deletes, model_inserts, model_updates
  11. from core.choices import JobStatusChoices, ObjectChangeActionChoices
  12. from core.events import *
  13. from extras.events import enqueue_event
  14. from extras.models import Tag
  15. from extras.utils import run_validators
  16. from netbox.config import get_config
  17. from netbox.context import current_request, events_queue
  18. from netbox.models.features import ChangeLoggingMixin
  19. from utilities.exceptions import AbortRequest
  20. from .models import ConfigRevision, DataSource, ObjectChange
  21. __all__ = (
  22. 'clear_events',
  23. 'job_end',
  24. 'job_start',
  25. 'post_sync',
  26. 'pre_sync',
  27. )
  28. # Job signals
  29. job_start = Signal()
  30. job_end = Signal()
  31. # DataSource signals
  32. pre_sync = Signal()
  33. post_sync = Signal()
  34. # Event signals
  35. clear_events = Signal()
  36. #
  37. # Change logging & event handling
  38. #
  39. # Used to track received signals per object
  40. _signals_received = local()
  41. @receiver((post_save, m2m_changed))
  42. def handle_changed_object(sender, instance, **kwargs):
  43. """
  44. Fires when an object is created or updated.
  45. """
  46. m2m_changed = False
  47. if not hasattr(instance, 'to_objectchange'):
  48. return
  49. # Get the current request, or bail if not set
  50. request = current_request.get()
  51. if request is None:
  52. return
  53. # Determine the type of change being made
  54. if kwargs.get('created'):
  55. event_type = OBJECT_CREATED
  56. elif 'created' in kwargs:
  57. event_type = OBJECT_UPDATED
  58. elif kwargs.get('action') in ['post_add', 'post_remove'] and kwargs['pk_set']:
  59. # m2m_changed with objects added or removed
  60. m2m_changed = True
  61. event_type = OBJECT_UPDATED
  62. elif kwargs.get('action') == 'post_clear':
  63. # Handle clearing of an M2M field
  64. if kwargs.get('model') == Tag and getattr(instance, '_prechange_snapshot', {}).get('tags'):
  65. # Handle generation of M2M changes for Tags which have a previous value (ignoring changes where the
  66. # prechange snapshot is empty)
  67. m2m_changed = True
  68. event_type = OBJECT_UPDATED
  69. else:
  70. # Other endpoints are unimpacted as they send post_add and post_remove
  71. # This will impact changes that utilize clear() however so we may want to give consideration for this branch
  72. return
  73. else:
  74. return
  75. # Create/update an ObjectChange record for this change
  76. action = {
  77. OBJECT_CREATED: ObjectChangeActionChoices.ACTION_CREATE,
  78. OBJECT_UPDATED: ObjectChangeActionChoices.ACTION_UPDATE,
  79. OBJECT_DELETED: ObjectChangeActionChoices.ACTION_DELETE,
  80. }[event_type]
  81. objectchange = instance.to_objectchange(action)
  82. # If this is a many-to-many field change, check for a previous ObjectChange instance recorded
  83. # for this object by this request and update it
  84. if m2m_changed and (
  85. prev_change := ObjectChange.objects.filter(
  86. changed_object_type=ContentType.objects.get_for_model(instance),
  87. changed_object_id=instance.pk,
  88. request_id=request.id
  89. ).first()
  90. ):
  91. prev_change.postchange_data = objectchange.postchange_data
  92. prev_change.save()
  93. elif objectchange and objectchange.has_changes:
  94. objectchange.user = request.user
  95. objectchange.request_id = request.id
  96. objectchange.save()
  97. # Ensure that we're working with fresh M2M assignments
  98. if m2m_changed:
  99. instance.refresh_from_db()
  100. # Enqueue the object for event processing
  101. queue = events_queue.get()
  102. enqueue_event(queue, instance, request.user, request.id, event_type)
  103. events_queue.set(queue)
  104. # Increment metric counters
  105. if event_type == OBJECT_CREATED:
  106. model_inserts.labels(instance._meta.model_name).inc()
  107. elif event_type == OBJECT_UPDATED:
  108. model_updates.labels(instance._meta.model_name).inc()
  109. @receiver(pre_delete)
  110. def handle_deleted_object(sender, instance, **kwargs):
  111. """
  112. Fires when an object is deleted.
  113. """
  114. # Run any deletion protection rules for the object. Note that this must occur prior
  115. # to queueing any events for the object being deleted, in case a validation error is
  116. # raised, causing the deletion to fail.
  117. model_name = f'{sender._meta.app_label}.{sender._meta.model_name}'
  118. validators = get_config().PROTECTION_RULES.get(model_name, [])
  119. try:
  120. run_validators(instance, validators)
  121. except ValidationError as e:
  122. raise AbortRequest(
  123. _("Deletion is prevented by a protection rule: {message}").format(message=e)
  124. )
  125. # Get the current request, or bail if not set
  126. request = current_request.get()
  127. if request is None:
  128. return
  129. # Check whether we've already processed a pre_delete signal for this object. (This can
  130. # happen e.g. when both a parent object and its child are deleted simultaneously, due
  131. # to cascading deletion.)
  132. if not hasattr(_signals_received, 'pre_delete'):
  133. _signals_received.pre_delete = set()
  134. signature = (ContentType.objects.get_for_model(instance), instance.pk)
  135. if signature in _signals_received.pre_delete:
  136. return
  137. _signals_received.pre_delete.add(signature)
  138. # Record an ObjectChange if applicable
  139. if hasattr(instance, 'to_objectchange'):
  140. if hasattr(instance, 'snapshot') and not getattr(instance, '_prechange_snapshot', None):
  141. instance.snapshot()
  142. objectchange = instance.to_objectchange(ObjectChangeActionChoices.ACTION_DELETE)
  143. objectchange.user = request.user
  144. objectchange.request_id = request.id
  145. objectchange.save()
  146. # Django does not automatically send an m2m_changed signal for the reverse direction of a
  147. # many-to-many relationship (see https://code.djangoproject.com/ticket/17688), so we need to
  148. # trigger one manually. We do this by checking for any reverse M2M relationships on the
  149. # instance being deleted, and explicitly call .remove() on the remote M2M field to delete
  150. # the association. This triggers an m2m_changed signal with the `post_remove` action type
  151. # for the forward direction of the relationship, ensuring that the change is recorded.
  152. # Similarly, for many-to-one relationships, we set the value on the related object to None
  153. # and save it to trigger a change record on that object.
  154. for relation in instance._meta.related_objects:
  155. if type(relation) not in [ManyToManyRel, ManyToOneRel]:
  156. continue
  157. related_model = relation.related_model
  158. related_field_name = relation.remote_field.name
  159. if not issubclass(related_model, ChangeLoggingMixin):
  160. # We only care about triggering the m2m_changed signal for models which support
  161. # change logging
  162. continue
  163. for obj in related_model.objects.filter(**{related_field_name: instance.pk}):
  164. obj.snapshot() # Ensure the change record includes the "before" state
  165. if type(relation) is ManyToManyRel:
  166. getattr(obj, related_field_name).remove(instance)
  167. elif type(relation) is ManyToOneRel and relation.field.null is True:
  168. setattr(obj, related_field_name, None)
  169. # make sure the object hasn't been deleted - in case of
  170. # deletion chaining of related objects
  171. try:
  172. obj.refresh_from_db()
  173. except DoesNotExist:
  174. continue
  175. obj.save()
  176. # Enqueue the object for event processing
  177. queue = events_queue.get()
  178. enqueue_event(queue, instance, request.user, request.id, OBJECT_DELETED)
  179. events_queue.set(queue)
  180. # Increment metric counters
  181. model_deletes.labels(instance._meta.model_name).inc()
  182. @receiver(request_finished)
  183. def clear_signal_history(sender, **kwargs):
  184. """
  185. Clear out the signals history once the request is finished.
  186. """
  187. _signals_received.pre_delete = set()
  188. @receiver(clear_events)
  189. def clear_events_queue(sender, **kwargs):
  190. """
  191. Delete any queued events (e.g. because of an aborted bulk transaction)
  192. """
  193. logger = logging.getLogger('events')
  194. logger.info(f"Clearing {len(events_queue.get())} queued events ({sender})")
  195. events_queue.set({})
  196. #
  197. # DataSource handlers
  198. #
  199. @receiver(post_save, sender=DataSource)
  200. def enqueue_sync_job(instance, created, **kwargs):
  201. """
  202. When a DataSource is saved, check its sync_interval and enqueue a sync job if appropriate.
  203. """
  204. from .jobs import SyncDataSourceJob
  205. if instance.enabled and instance.sync_interval:
  206. SyncDataSourceJob.enqueue_once(instance, interval=instance.sync_interval)
  207. elif not created:
  208. # Delete any previously scheduled recurring jobs for this DataSource
  209. for job in SyncDataSourceJob.get_jobs(instance).defer('data').filter(
  210. interval__isnull=False,
  211. status=JobStatusChoices.STATUS_SCHEDULED
  212. ):
  213. # Call delete() per instance to ensure the associated background task is deleted as well
  214. job.delete()
  215. @receiver(post_sync)
  216. def auto_sync(instance, **kwargs):
  217. """
  218. Automatically synchronize any DataFiles with AutoSyncRecords after synchronizing a DataSource.
  219. """
  220. from .models import AutoSyncRecord
  221. for autosync in AutoSyncRecord.objects.filter(datafile__source=instance).prefetch_related('object'):
  222. autosync.object.sync(save=True)
  223. @receiver(post_save, sender=ConfigRevision)
  224. def update_config(sender, instance, **kwargs):
  225. """
  226. Update the cached NetBox configuration when a new ConfigRevision is created.
  227. """
  228. instance.activate()