signals.py 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267
  1. import importlib
  2. import logging
  3. from django.contrib.contenttypes.models import ContentType
  4. from django.core.exceptions import ValidationError
  5. from django.db.models.signals import m2m_changed, post_save, pre_delete
  6. from django.dispatch import receiver, Signal
  7. from django.utils.translation import gettext_lazy as _
  8. from django_prometheus.models import model_deletes, model_inserts, model_updates
  9. from core.signals import job_end, job_start
  10. from extras.constants import EVENT_JOB_END, EVENT_JOB_START
  11. from extras.events import process_event_rules
  12. from extras.models import EventRule
  13. from extras.validators import CustomValidator
  14. from netbox.config import get_config
  15. from netbox.context import current_request, events_queue
  16. from netbox.signals import post_clean
  17. from utilities.exceptions import AbortRequest
  18. from .choices import ObjectChangeActionChoices
  19. from .events import enqueue_object, get_snapshots, serialize_for_event
  20. from .models import CustomField, ObjectChange, TaggedItem
  21. #
  22. # Change logging/webhooks
  23. #
  24. # Define a custom signal that can be sent to clear any queued events
  25. clear_events = Signal()
  26. def is_same_object(instance, webhook_data, request_id):
  27. """
  28. Compare the given instance to the most recent queued webhook object, returning True
  29. if they match. This check is used to avoid creating duplicate webhook entries.
  30. """
  31. return (
  32. ContentType.objects.get_for_model(instance) == webhook_data['content_type'] and
  33. instance.pk == webhook_data['object_id'] and
  34. request_id == webhook_data['request_id']
  35. )
  36. @receiver((post_save, m2m_changed))
  37. def handle_changed_object(sender, instance, **kwargs):
  38. """
  39. Fires when an object is created or updated.
  40. """
  41. m2m_changed = False
  42. if not hasattr(instance, 'to_objectchange'):
  43. return
  44. # Get the current request, or bail if not set
  45. request = current_request.get()
  46. if request is None:
  47. return
  48. # Determine the type of change being made
  49. if kwargs.get('created'):
  50. action = ObjectChangeActionChoices.ACTION_CREATE
  51. elif 'created' in kwargs:
  52. action = ObjectChangeActionChoices.ACTION_UPDATE
  53. elif kwargs.get('action') in ['post_add', 'post_remove'] and kwargs['pk_set']:
  54. # m2m_changed with objects added or removed
  55. m2m_changed = True
  56. action = ObjectChangeActionChoices.ACTION_UPDATE
  57. else:
  58. return
  59. # Create/update an ObejctChange record for this change
  60. objectchange = instance.to_objectchange(action)
  61. # If this is a many-to-many field change, check for a previous ObjectChange instance recorded
  62. # for this object by this request and update it
  63. if m2m_changed and (
  64. prev_change := ObjectChange.objects.filter(
  65. changed_object_type=ContentType.objects.get_for_model(instance),
  66. changed_object_id=instance.pk,
  67. request_id=request.id
  68. ).first()
  69. ):
  70. prev_change.postchange_data = objectchange.postchange_data
  71. prev_change.save()
  72. elif objectchange and objectchange.has_changes:
  73. objectchange.user = request.user
  74. objectchange.request_id = request.id
  75. objectchange.save()
  76. # If this is an M2M change, update the previously queued webhook (from post_save)
  77. queue = events_queue.get()
  78. if m2m_changed and queue and is_same_object(instance, queue[-1], request.id):
  79. instance.refresh_from_db() # Ensure that we're working with fresh M2M assignments
  80. queue[-1]['data'] = serialize_for_event(instance)
  81. queue[-1]['snapshots']['postchange'] = get_snapshots(instance, action)['postchange']
  82. else:
  83. enqueue_object(queue, instance, request.user, request.id, action)
  84. events_queue.set(queue)
  85. # Increment metric counters
  86. if action == ObjectChangeActionChoices.ACTION_CREATE:
  87. model_inserts.labels(instance._meta.model_name).inc()
  88. elif action == ObjectChangeActionChoices.ACTION_UPDATE:
  89. model_updates.labels(instance._meta.model_name).inc()
  90. @receiver(pre_delete)
  91. def handle_deleted_object(sender, instance, **kwargs):
  92. """
  93. Fires when an object is deleted.
  94. """
  95. # Get the current request, or bail if not set
  96. request = current_request.get()
  97. if request is None:
  98. return
  99. # Record an ObjectChange if applicable
  100. if hasattr(instance, 'to_objectchange'):
  101. if hasattr(instance, 'snapshot') and not getattr(instance, '_prechange_snapshot', None):
  102. instance.snapshot()
  103. objectchange = instance.to_objectchange(ObjectChangeActionChoices.ACTION_DELETE)
  104. objectchange.user = request.user
  105. objectchange.request_id = request.id
  106. objectchange.save()
  107. # Enqueue webhooks
  108. queue = events_queue.get()
  109. enqueue_object(queue, instance, request.user, request.id, ObjectChangeActionChoices.ACTION_DELETE)
  110. events_queue.set(queue)
  111. # Increment metric counters
  112. model_deletes.labels(instance._meta.model_name).inc()
  113. @receiver(clear_events)
  114. def clear_events_queue(sender, **kwargs):
  115. """
  116. Delete any queued events (e.g. because of an aborted bulk transaction)
  117. """
  118. logger = logging.getLogger('events')
  119. logger.info(f"Clearing {len(events_queue.get())} queued events ({sender})")
  120. events_queue.set([])
  121. #
  122. # Custom fields
  123. #
  124. def handle_cf_added_obj_types(instance, action, pk_set, **kwargs):
  125. """
  126. Handle the population of default/null values when a CustomField is added to one or more ContentTypes.
  127. """
  128. if action == 'post_add':
  129. instance.populate_initial_data(ContentType.objects.filter(pk__in=pk_set))
  130. def handle_cf_removed_obj_types(instance, action, pk_set, **kwargs):
  131. """
  132. Handle the cleanup of old custom field data when a CustomField is removed from one or more ContentTypes.
  133. """
  134. if action == 'post_remove':
  135. instance.remove_stale_data(ContentType.objects.filter(pk__in=pk_set))
  136. def handle_cf_renamed(instance, created, **kwargs):
  137. """
  138. Handle the renaming of custom field data on objects when a CustomField is renamed.
  139. """
  140. if not created and instance.name != instance._name:
  141. instance.rename_object_data(old_name=instance._name, new_name=instance.name)
  142. def handle_cf_deleted(instance, **kwargs):
  143. """
  144. Handle the cleanup of old custom field data when a CustomField is deleted.
  145. """
  146. instance.remove_stale_data(instance.content_types.all())
  147. post_save.connect(handle_cf_renamed, sender=CustomField)
  148. pre_delete.connect(handle_cf_deleted, sender=CustomField)
  149. m2m_changed.connect(handle_cf_added_obj_types, sender=CustomField.content_types.through)
  150. m2m_changed.connect(handle_cf_removed_obj_types, sender=CustomField.content_types.through)
  151. #
  152. # Custom validation
  153. #
  154. def run_validators(instance, validators):
  155. for validator in validators:
  156. # Loading a validator class by dotted path
  157. if type(validator) is str:
  158. module, cls = validator.rsplit('.', 1)
  159. validator = getattr(importlib.import_module(module), cls)()
  160. # Constructing a new instance on the fly from a ruleset
  161. elif type(validator) is dict:
  162. validator = CustomValidator(validator)
  163. validator(instance)
  164. @receiver(post_clean)
  165. def run_save_validators(sender, instance, **kwargs):
  166. model_name = f'{sender._meta.app_label}.{sender._meta.model_name}'
  167. validators = get_config().CUSTOM_VALIDATORS.get(model_name, [])
  168. run_validators(instance, validators)
  169. @receiver(pre_delete)
  170. def run_delete_validators(sender, instance, **kwargs):
  171. model_name = f'{sender._meta.app_label}.{sender._meta.model_name}'
  172. validators = get_config().PROTECTION_RULES.get(model_name, [])
  173. try:
  174. run_validators(instance, validators)
  175. except ValidationError as e:
  176. raise AbortRequest(
  177. _("Deletion is prevented by a protection rule: {message}").format(
  178. message=e
  179. )
  180. )
  181. #
  182. # Tags
  183. #
  184. @receiver(m2m_changed, sender=TaggedItem)
  185. def validate_assigned_tags(sender, instance, action, model, pk_set, **kwargs):
  186. """
  187. Validate that any Tags being assigned to the instance are not restricted to non-applicable object types.
  188. """
  189. if action != 'pre_add':
  190. return
  191. ct = ContentType.objects.get_for_model(instance)
  192. # Retrieve any applied Tags that are restricted to certain object_types
  193. for tag in model.objects.filter(pk__in=pk_set, object_types__isnull=False).prefetch_related('object_types'):
  194. if ct not in tag.object_types.all():
  195. raise AbortRequest(f"Tag {tag} cannot be assigned to {ct.model} objects.")
  196. #
  197. # Event rules
  198. #
  199. @receiver(job_start)
  200. def process_job_start_event_rules(sender, **kwargs):
  201. """
  202. Process event rules for jobs starting.
  203. """
  204. event_rules = EventRule.objects.filter(type_job_start=True, enabled=True, content_types=sender.object_type)
  205. username = sender.user.username if sender.user else None
  206. process_event_rules(event_rules, sender.object_type.model, EVENT_JOB_START, sender.data, username)
  207. @receiver(job_end)
  208. def process_job_end_event_rules(sender, **kwargs):
  209. """
  210. Process event rules for jobs terminating.
  211. """
  212. event_rules = EventRule.objects.filter(type_job_end=True, enabled=True, content_types=sender.object_type)
  213. username = sender.user.username if sender.user else None
  214. process_event_rules(event_rules, sender.object_type.model, EVENT_JOB_END, sender.data, username)