2
0

signals.py 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278
  1. import logging
  2. from django.db.models import Q
  3. from django.db.models.signals import post_delete, post_save
  4. from django.dispatch import receiver
  5. from dcim.choices import CableEndChoices, LinkStatusChoices
  6. from ipam.models import Prefix
  7. from netbox.search.backends import search_backend
  8. from virtualization.models import Cluster, VMInterface
  9. from wireless.models import WirelessLAN
  10. from .models import (
  11. Cable,
  12. CablePath,
  13. CableTermination,
  14. ConsolePort,
  15. ConsoleServerPort,
  16. Device,
  17. DeviceBay,
  18. FrontPort,
  19. Interface,
  20. InventoryItem,
  21. Location,
  22. ModuleBay,
  23. PathEndpoint,
  24. PortMapping,
  25. PowerOutlet,
  26. PowerPanel,
  27. PowerPort,
  28. Rack,
  29. RearPort,
  30. Site,
  31. VirtualChassis,
  32. )
  33. from .models.cables import trace_paths
  34. from .search import DeviceIndex
  35. from .utils import create_cablepaths, rebuild_paths
  36. COMPONENT_MODELS = (
  37. ConsolePort,
  38. ConsoleServerPort,
  39. DeviceBay,
  40. FrontPort,
  41. Interface,
  42. InventoryItem,
  43. ModuleBay,
  44. PowerOutlet,
  45. PowerPort,
  46. RearPort,
  47. )
  48. #
  49. # Location/rack/device assignment
  50. #
  51. @receiver(post_save, sender=Location)
  52. def handle_location_site_change(instance, created, **kwargs):
  53. """
  54. Update child objects if Site assignment has changed. We intentionally recurse through each child
  55. object instead of calling update() on the QuerySet to ensure the proper change records get created for each.
  56. """
  57. if not created:
  58. instance.get_descendants().update(site=instance.site)
  59. locations = instance.get_descendants(include_self=True).values_list('pk', flat=True)
  60. Rack.objects.filter(location__in=locations).update(site=instance.site)
  61. Device.objects.filter(location__in=locations).update(site=instance.site)
  62. PowerPanel.objects.filter(location__in=locations).update(site=instance.site)
  63. CableTermination.objects.filter(_location__in=locations).update(_site=instance.site)
  64. # Update component models for devices in these locations
  65. for model in COMPONENT_MODELS:
  66. model.objects.filter(device__location__in=locations).update(_site=instance.site)
  67. @receiver(post_save, sender=Rack)
  68. def handle_rack_site_change(instance, created, **kwargs):
  69. """
  70. Update child Devices if Site or Location assignment has changed.
  71. """
  72. if not created:
  73. Device.objects.filter(rack=instance).update(site=instance.site, location=instance.location)
  74. # Update component models for devices in this rack
  75. for model in COMPONENT_MODELS:
  76. model.objects.filter(device__rack=instance).update(
  77. _site=instance.site,
  78. _location=instance.location,
  79. )
  80. @receiver(post_save, sender=Device)
  81. def handle_device_site_change(instance, created, **kwargs):
  82. """
  83. Update child components to update the parent Site, Location, and Rack when a Device is saved.
  84. """
  85. if not created:
  86. for model in COMPONENT_MODELS:
  87. model.objects.filter(device=instance).update(
  88. _site=instance.site,
  89. _location=instance.location,
  90. _rack=instance.rack,
  91. )
  92. #
  93. # Virtual chassis
  94. #
  95. @receiver(post_save, sender=VirtualChassis)
  96. def assign_virtualchassis_master(instance, created, **kwargs):
  97. """
  98. When a VirtualChassis is created, automatically assign its master device (if any) to the VC.
  99. """
  100. if created and instance.master:
  101. master = Device.objects.get(pk=instance.master.pk)
  102. master.virtual_chassis = instance
  103. master.vc_position = 1
  104. master.save()
  105. @receiver(post_save, sender=VirtualChassis)
  106. def update_virtualchassis_member_search_cache(instance, created, raw=False, update_fields=None, **kwargs):
  107. """
  108. Refresh the search cache for member Devices when a VirtualChassis is renamed. DeviceIndex caches
  109. virtual_chassis as its string value, so a rename would otherwise leave stale CachedValue entries.
  110. """
  111. if raw or created:
  112. return
  113. # The VC name is the only VC attribute cached on member Devices; skip saves that can't change it.
  114. if update_fields is not None and 'name' not in update_fields:
  115. return
  116. search_backend.cache(
  117. Device.objects.filter(virtual_chassis=instance).select_related('virtual_chassis'),
  118. indexer=DeviceIndex,
  119. remove_existing=True
  120. )
  121. #
  122. # Cables
  123. #
  124. @receiver(trace_paths, sender=Cable)
  125. def update_connected_endpoints(instance, created, raw=False, **kwargs):
  126. """
  127. When a Cable is saved with new terminations, retrace any affected cable paths.
  128. """
  129. logger = logging.getLogger('netbox.dcim.cable')
  130. if raw:
  131. logger.debug(f"Skipping endpoint updates for imported cable {instance}")
  132. return
  133. # Update cable paths if new terminations have been set
  134. if instance._terminations_modified:
  135. a_terminations = []
  136. b_terminations = []
  137. # Note: instance.terminations.all() is not safe to use here as it might be stale
  138. for t in CableTermination.objects.filter(cable=instance):
  139. if t.cable_end == CableEndChoices.SIDE_A:
  140. a_terminations.append(t.termination)
  141. else:
  142. b_terminations.append(t.termination)
  143. for nodes in [a_terminations, b_terminations]:
  144. # Examine type of first termination to determine object type (all must be the same)
  145. if not nodes:
  146. continue
  147. if isinstance(nodes[0], PathEndpoint):
  148. create_cablepaths(nodes)
  149. else:
  150. rebuild_paths(nodes)
  151. # Update status of CablePaths if Cable status has been changed
  152. elif instance.status != instance._orig_status:
  153. if instance.status != LinkStatusChoices.STATUS_CONNECTED:
  154. CablePath.objects.filter(_nodes__contains=instance).update(is_active=False)
  155. else:
  156. rebuild_paths([instance])
  157. @receiver(post_delete, sender=Cable)
  158. def retrace_cable_paths(instance, **kwargs):
  159. """
  160. When a Cable is deleted, check for and update its connected endpoints
  161. """
  162. for cablepath in CablePath.objects.filter(_nodes__contains=instance):
  163. cablepath.retrace()
  164. @receiver((post_delete, post_save), sender=PortMapping)
  165. def update_passthrough_port_paths(instance, **kwargs):
  166. """
  167. When a PortMapping is created or deleted, retrace any CablePaths which traverse its front and/or rear ports.
  168. """
  169. for cablepath in CablePath.objects.filter(
  170. Q(_nodes__contains=instance.front_port) | Q(_nodes__contains=instance.rear_port)
  171. ):
  172. cablepath.retrace()
  173. @receiver(post_delete, sender=CableTermination)
  174. def nullify_connected_endpoints(instance, **kwargs):
  175. """
  176. Disassociate the Cable from the termination object, and retrace any affected CablePaths.
  177. """
  178. model = instance.termination_type.model_class()
  179. model.objects.filter(pk=instance.termination_id).update(
  180. cable=None,
  181. cable_end='',
  182. cable_connector=None,
  183. cable_positions=None,
  184. )
  185. # If the parent Cable is being deleted in this same operation, skip the
  186. # per-termination retrace; retrace_cable_paths() will retrace each affected
  187. # path once after the Cable is deleted.
  188. if Cable._is_being_deleted(instance.cable_id):
  189. return
  190. for cablepath in CablePath.objects.filter(_nodes__contains=instance.cable):
  191. # Remove the deleted CableTermination if it's one of the path's originating nodes
  192. if instance.termination in cablepath.origins:
  193. cablepath.origins.remove(instance.termination)
  194. # Clear _path on the removed origin to prevent stale connection display
  195. model.objects.filter(pk=instance.termination_id, _path=cablepath.pk).update(_path=None)
  196. cablepath.retrace()
  197. @receiver(post_save, sender=Interface)
  198. @receiver(post_save, sender=VMInterface)
  199. def update_mac_address_interface(instance, created, raw, **kwargs):
  200. """
  201. When creating a new Interface or VMInterface, check whether a MACAddress has been designated as its primary. If so,
  202. assign the MACAddress to the interface.
  203. """
  204. if created and not raw and instance.primary_mac_address:
  205. instance.primary_mac_address.assigned_object = instance
  206. instance.primary_mac_address.save()
  207. @receiver(post_save, sender=Location)
  208. @receiver(post_save, sender=Site)
  209. def sync_cached_scope_fields(instance, created, **kwargs):
  210. """
  211. Rebuild cached scope fields for all CachedScopeMixin-based models
  212. affected by a change in a Region, SiteGroup, Site, or Location.
  213. This method is safe to run for objects created in the past and does
  214. not rely on incremental updates. Cached fields are recomputed from
  215. authoritative relationships.
  216. """
  217. if created:
  218. return
  219. if isinstance(instance, Location):
  220. filters = {'_location': instance}
  221. elif isinstance(instance, Site):
  222. filters = {'_site': instance}
  223. else:
  224. return
  225. # These models are explicitly listed because they all subclass CachedScopeMixin
  226. # and therefore require their cached scope fields to be recomputed.
  227. for model in (Prefix, Cluster, WirelessLAN):
  228. qs = model.objects.filter(**filters)
  229. # Bulk update cached fields to avoid O(N) performance issues with large datasets.
  230. # This does not trigger post_save signals, avoiding spurious change log entries.
  231. objects_to_update = []
  232. for obj in qs:
  233. # Recompute cache using the same logic as save()
  234. obj.cache_related_objects()
  235. objects_to_update.append(obj)
  236. if objects_to_update:
  237. model.objects.bulk_update(
  238. objects_to_update,
  239. ['_location', '_site', '_site_group', '_region']
  240. )