signals.py 7.7 KB

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