test_signals.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518
  1. from types import SimpleNamespace
  2. from unittest.mock import MagicMock, patch
  3. from django.contrib.contenttypes.models import ContentType
  4. from django.test import SimpleTestCase, TestCase
  5. from dcim import signals
  6. from dcim.choices import CableEndChoices, CableProfileChoices, LinkStatusChoices
  7. from dcim.models import (
  8. Cable,
  9. CablePath,
  10. Device,
  11. DeviceRole,
  12. DeviceType,
  13. FrontPort,
  14. Interface,
  15. Location,
  16. MACAddress,
  17. Manufacturer,
  18. PortMapping,
  19. PowerPanel,
  20. Rack,
  21. RearPort,
  22. Site,
  23. SiteGroup,
  24. VirtualChassis,
  25. )
  26. from ipam.models import Prefix
  27. from virtualization.models import Cluster, ClusterType
  28. from wireless.models import WirelessLAN
  29. class LocationSiteChangeSignalTestCase(TestCase):
  30. """
  31. Verify dcim.signals.handle_location_site_change propagates a Location's new Site to
  32. every descendant Location, Rack, Device, PowerPanel, and component when the parent
  33. Location's site assignment changes.
  34. """
  35. @classmethod
  36. def setUpTestData(cls):
  37. cls.site_a = Site.objects.create(name='Site A', slug='site-a')
  38. cls.site_b = Site.objects.create(name='Site B', slug='site-b')
  39. manufacturer = Manufacturer.objects.create(name='Manufacturer', slug='manufacturer')
  40. cls.device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type')
  41. cls.device_role = DeviceRole.objects.create(name='Device Role', slug='device-role')
  42. def test_changing_location_site_propagates_to_children(self):
  43. parent_location = Location.objects.create(name='Parent', slug='parent', site=self.site_a)
  44. child_location = Location.objects.create(name='Child', slug='child', site=self.site_a, parent=parent_location)
  45. rack = Rack.objects.create(name='Rack', site=self.site_a, location=parent_location)
  46. device = Device.objects.create(
  47. name='Device',
  48. site=self.site_a,
  49. location=parent_location,
  50. device_type=self.device_type,
  51. role=self.device_role,
  52. )
  53. interface = Interface.objects.create(device=device, name='Interface 1')
  54. power_panel = PowerPanel.objects.create(name='Panel', site=self.site_a, location=parent_location)
  55. parent_location.site = self.site_b
  56. parent_location.save()
  57. child_location.refresh_from_db()
  58. rack.refresh_from_db()
  59. device.refresh_from_db()
  60. interface.refresh_from_db()
  61. power_panel.refresh_from_db()
  62. self.assertEqual(child_location.site, self.site_b)
  63. self.assertEqual(rack.site, self.site_b)
  64. self.assertEqual(device.site, self.site_b)
  65. self.assertEqual(interface._site, self.site_b)
  66. self.assertEqual(power_panel.site, self.site_b)
  67. def test_creating_location_does_not_attempt_to_propagate(self):
  68. # Should not raise — newly-created locations have no descendants.
  69. Location.objects.create(name='New', slug='new', site=self.site_a)
  70. class RackSiteChangeSignalTestCase(TestCase):
  71. """
  72. Verify dcim.signals.handle_rack_site_change propagates a Rack's site/location to its
  73. Devices and their components when the Rack is moved.
  74. """
  75. @classmethod
  76. def setUpTestData(cls):
  77. cls.site_a = Site.objects.create(name='Site A', slug='site-a')
  78. cls.site_b = Site.objects.create(name='Site B', slug='site-b')
  79. cls.location_b = Location.objects.create(name='Loc B', slug='loc-b', site=cls.site_b)
  80. manufacturer = Manufacturer.objects.create(name='Manufacturer', slug='manufacturer')
  81. cls.device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type')
  82. cls.device_role = DeviceRole.objects.create(name='Device Role', slug='device-role')
  83. def test_changing_rack_site_propagates_to_devices_and_components(self):
  84. rack = Rack.objects.create(name='Rack', site=self.site_a)
  85. device = Device.objects.create(
  86. name='Device',
  87. site=self.site_a,
  88. rack=rack,
  89. device_type=self.device_type,
  90. role=self.device_role,
  91. )
  92. interface = Interface.objects.create(device=device, name='Interface 1')
  93. rack.site = self.site_b
  94. rack.location = self.location_b
  95. rack.save()
  96. device.refresh_from_db()
  97. interface.refresh_from_db()
  98. self.assertEqual(device.site, self.site_b)
  99. self.assertEqual(device.location, self.location_b)
  100. self.assertEqual(interface._site, self.site_b)
  101. self.assertEqual(interface._location, self.location_b)
  102. class DeviceSiteChangeSignalTestCase(TestCase):
  103. """
  104. Verify dcim.signals.handle_device_site_change propagates a Device's site/location/rack
  105. to its components on save.
  106. """
  107. @classmethod
  108. def setUpTestData(cls):
  109. cls.site_a = Site.objects.create(name='Site A', slug='site-a')
  110. cls.site_b = Site.objects.create(name='Site B', slug='site-b')
  111. manufacturer = Manufacturer.objects.create(name='Manufacturer', slug='manufacturer')
  112. cls.device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type')
  113. cls.device_role = DeviceRole.objects.create(name='Device Role', slug='device-role')
  114. def test_moving_device_updates_components_cached_scope(self):
  115. device = Device.objects.create(
  116. name='Device',
  117. site=self.site_a,
  118. device_type=self.device_type,
  119. role=self.device_role,
  120. )
  121. interface = Interface.objects.create(device=device, name='Interface 1')
  122. self.assertEqual(interface._site, self.site_a)
  123. device.site = self.site_b
  124. device.save()
  125. interface.refresh_from_db()
  126. self.assertEqual(interface._site, self.site_b)
  127. class VirtualChassisMasterSignalTestCase(TestCase):
  128. """
  129. Verify dcim.signals.assign_virtualchassis_master links the master device back to a
  130. newly-created VirtualChassis.
  131. """
  132. @classmethod
  133. def setUpTestData(cls):
  134. cls.site = Site.objects.create(name='Site', slug='site')
  135. manufacturer = Manufacturer.objects.create(name='Manufacturer', slug='manufacturer')
  136. cls.device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type')
  137. cls.device_role = DeviceRole.objects.create(name='Device Role', slug='device-role')
  138. def test_master_is_assigned_to_new_virtual_chassis(self):
  139. master = Device.objects.create(
  140. name='Master',
  141. site=self.site,
  142. device_type=self.device_type,
  143. role=self.device_role,
  144. )
  145. vc = VirtualChassis.objects.create(name='VC 1', master=master)
  146. master.refresh_from_db()
  147. self.assertEqual(master.virtual_chassis, vc)
  148. self.assertEqual(master.vc_position, 1)
  149. def test_updating_virtual_chassis_does_not_reassign_master(self):
  150. master = Device.objects.create(
  151. name='Master',
  152. site=self.site,
  153. device_type=self.device_type,
  154. role=self.device_role,
  155. )
  156. vc = VirtualChassis.objects.create(name='VC 1', master=master)
  157. # Detach the master, then save the VC again — the signal should not re-link.
  158. master.virtual_chassis = None
  159. master.vc_position = None
  160. master.save()
  161. vc.domain = 'updated'
  162. vc.save()
  163. master.refresh_from_db()
  164. self.assertIsNone(master.virtual_chassis)
  165. class CableSignalTestCase(TestCase):
  166. """
  167. Verify dcim.signals.update_connected_endpoints, retrace_cable_paths, and
  168. nullify_connected_endpoints maintain CablePaths in response to Cable lifecycle events.
  169. """
  170. @classmethod
  171. def setUpTestData(cls):
  172. cls.site = Site.objects.create(name='Site', slug='site')
  173. manufacturer = Manufacturer.objects.create(name='Manufacturer', slug='manufacturer')
  174. device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type')
  175. role = DeviceRole.objects.create(name='Device Role', slug='device-role')
  176. cls.device = Device.objects.create(
  177. name='Device',
  178. site=cls.site,
  179. device_type=device_type,
  180. role=role,
  181. )
  182. def test_creating_cable_creates_endpoint_paths(self):
  183. interface_a = Interface.objects.create(device=self.device, name='Interface A')
  184. interface_b = Interface.objects.create(device=self.device, name='Interface B')
  185. cable = Cable(a_terminations=[interface_a], b_terminations=[interface_b])
  186. cable.save()
  187. self.assertEqual(CablePath.objects.count(), 2)
  188. interface_a.refresh_from_db()
  189. self.assertIsNotNone(interface_a._path_id)
  190. def test_changing_cable_status_marks_paths_inactive(self):
  191. interface_a = Interface.objects.create(device=self.device, name='Interface A')
  192. interface_b = Interface.objects.create(device=self.device, name='Interface B')
  193. cable = Cable(a_terminations=[interface_a], b_terminations=[interface_b])
  194. cable.save()
  195. self.assertTrue(all(cp.is_active for cp in CablePath.objects.all()))
  196. # Reload the cable so _orig_status reflects the persisted value and
  197. # _terminations_modified resets to False.
  198. cable = Cable.objects.get(pk=cable.pk)
  199. cable.status = LinkStatusChoices.STATUS_PLANNED
  200. cable.save()
  201. self.assertFalse(any(cp.is_active for cp in CablePath.objects.all()))
  202. def test_reconnecting_cable_marks_paths_active(self):
  203. interface_a = Interface.objects.create(device=self.device, name='Interface A')
  204. interface_b = Interface.objects.create(device=self.device, name='Interface B')
  205. cable = Cable(
  206. a_terminations=[interface_a],
  207. b_terminations=[interface_b],
  208. status=LinkStatusChoices.STATUS_PLANNED,
  209. )
  210. cable.save()
  211. self.assertFalse(any(cp.is_active for cp in CablePath.objects.all()))
  212. cable = Cable.objects.get(pk=cable.pk)
  213. cable.status = LinkStatusChoices.STATUS_CONNECTED
  214. cable.save()
  215. self.assertTrue(all(cp.is_active for cp in CablePath.objects.all()))
  216. def test_deleting_cable_retraces_paths(self):
  217. interface_a = Interface.objects.create(device=self.device, name='Interface A')
  218. interface_b = Interface.objects.create(device=self.device, name='Interface B')
  219. cable = Cable(a_terminations=[interface_a], b_terminations=[interface_b])
  220. cable.save()
  221. self.assertEqual(CablePath.objects.count(), 2)
  222. cable.delete()
  223. self.assertEqual(CablePath.objects.count(), 0)
  224. interface_a.refresh_from_db()
  225. interface_b.refresh_from_db()
  226. # Cable deletion must fully detach both endpoints, even though the
  227. # nullify_connected_endpoints signal short-circuits during Cable cascade.
  228. self.assertIsNone(interface_a._path_id)
  229. self.assertIsNone(interface_b._path_id)
  230. self.assertIsNone(interface_a.cable_id)
  231. self.assertIsNone(interface_b.cable_id)
  232. self.assertEqual(interface_a.cable_end, '')
  233. self.assertEqual(interface_b.cable_end, '')
  234. def test_deleting_profiled_cable_nullifies_endpoints(self):
  235. """
  236. Deleting a profiled cable must clear the cached connector and position data on both endpoints.
  237. """
  238. interface_a = Interface.objects.create(device=self.device, name='Interface A')
  239. interface_b = Interface.objects.create(device=self.device, name='Interface B')
  240. cable = Cable(
  241. a_terminations=[interface_a],
  242. b_terminations=[interface_b],
  243. profile=CableProfileChoices.SINGLE_1C1P,
  244. )
  245. cable.save()
  246. # Confirm the profile metadata was cached on both endpoints.
  247. interface_a.refresh_from_db()
  248. interface_b.refresh_from_db()
  249. self.assertEqual(interface_a.cable_connector, 1)
  250. self.assertEqual(interface_a.cable_positions, [1])
  251. self.assertEqual(interface_b.cable_connector, 1)
  252. self.assertEqual(interface_b.cable_positions, [1])
  253. cable.delete()
  254. for interface in (interface_a, interface_b):
  255. interface.refresh_from_db()
  256. self.assertIsNone(interface.cable_id)
  257. self.assertEqual(interface.cable_end, '')
  258. self.assertIsNone(interface.cable_connector)
  259. self.assertIsNone(interface.cable_positions)
  260. def test_deleting_cable_skips_per_termination_retrace(self):
  261. """
  262. When a Cable is deleted, nullify_connected_endpoints (post_delete on each
  263. cascaded CableTermination) must skip retracing — retrace_cable_paths
  264. retraces each affected path once on Cable post_delete instead. See #22104.
  265. Without the short-circuit, retrace would fire (n_terminations * n_paths)
  266. times from the per-termination handler plus n_paths times from the Cable
  267. handler — for this 2-termination, 2-path cable, 6 calls total. With the
  268. short-circuit, only the n_paths calls from retrace_cable_paths remain.
  269. """
  270. interface_a = Interface.objects.create(device=self.device, name='Interface A')
  271. interface_b = Interface.objects.create(device=self.device, name='Interface B')
  272. cable = Cable(a_terminations=[interface_a], b_terminations=[interface_b])
  273. cable.save()
  274. self.assertEqual(CablePath.objects.count(), 2)
  275. self.assertFalse(Cable._is_being_deleted(cable.pk))
  276. with patch('dcim.models.cables.CablePath.retrace') as retrace:
  277. cable.delete()
  278. # Exactly one retrace per affected CablePath (from retrace_cable_paths),
  279. # not the n*m calls the per-termination handler would have made.
  280. self.assertEqual(retrace.call_count, 2)
  281. # The deletion-tracking set must be cleaned up after delete() returns,
  282. # even when the cascade runs to completion.
  283. self.assertFalse(Cable._is_being_deleted(cable.pk))
  284. def test_creating_portmapping_retraces_dependent_paths(self):
  285. interface = Interface.objects.create(device=self.device, name='Interface A')
  286. front_port = FrontPort.objects.create(device=self.device, name='Front Port 1')
  287. rear_port = RearPort.objects.create(device=self.device, name='Rear Port 1')
  288. Cable(a_terminations=[interface], b_terminations=[front_port]).save()
  289. # Creating a PortMapping connecting the front and rear ports should retrace paths
  290. # that traverse either port (i.e. the incomplete path through front_port).
  291. PortMapping.objects.create(
  292. device=self.device,
  293. front_port=front_port,
  294. front_port_position=1,
  295. rear_port=rear_port,
  296. rear_port_position=1,
  297. )
  298. path = CablePath.objects.filter(_nodes__contains=front_port).first()
  299. self.assertIsNotNone(path)
  300. # The retraced path should now extend through to the rear port. Path nodes are
  301. # encoded as "<content_type_id>:<object_id>".
  302. rear_port_node = f'{ContentType.objects.get_for_model(RearPort).pk}:{rear_port.pk}'
  303. flat_nodes = [n for step in path.path for n in step]
  304. self.assertIn(rear_port_node, flat_nodes)
  305. def test_deleting_cabletermination_nullifies_endpoints(self):
  306. interface_a = Interface.objects.create(device=self.device, name='Interface A')
  307. interface_b = Interface.objects.create(device=self.device, name='Interface B')
  308. cable = Cable(a_terminations=[interface_a], b_terminations=[interface_b])
  309. cable.save()
  310. termination = cable.terminations.get(cable_end=CableEndChoices.SIDE_A)
  311. termination.delete()
  312. interface_a.refresh_from_db()
  313. self.assertIsNone(interface_a.cable_id)
  314. self.assertEqual(interface_a.cable_end, '')
  315. class MACAddressInterfaceSignalTestCase(TestCase):
  316. """
  317. Verify dcim.signals.update_mac_address_interface assigns a designated primary MAC to
  318. the newly-created Interface or VMInterface.
  319. """
  320. @classmethod
  321. def setUpTestData(cls):
  322. cls.site = Site.objects.create(name='Site', slug='site')
  323. manufacturer = Manufacturer.objects.create(name='Manufacturer', slug='manufacturer')
  324. device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type')
  325. role = DeviceRole.objects.create(name='Device Role', slug='device-role')
  326. cls.device = Device.objects.create(
  327. name='Device',
  328. site=cls.site,
  329. device_type=device_type,
  330. role=role,
  331. )
  332. def test_primary_mac_is_assigned_to_new_interface(self):
  333. mac = MACAddress.objects.create(mac_address='00:11:22:33:44:55')
  334. interface = Interface(device=self.device, name='Interface 1', primary_mac_address=mac)
  335. interface.save()
  336. mac.refresh_from_db()
  337. self.assertEqual(mac.assigned_object, interface)
  338. def test_primary_mac_is_not_reassigned_on_interface_update(self):
  339. mac = MACAddress.objects.create(mac_address='00:11:22:33:44:55')
  340. interface = Interface.objects.create(device=self.device, name='Interface 1')
  341. mac.assigned_object = interface
  342. mac.save()
  343. # Detach (simulate the MAC having been moved off the interface).
  344. mac.assigned_object = None
  345. mac.save()
  346. interface.primary_mac_address = mac
  347. interface.description = 'updated'
  348. interface.save()
  349. mac.refresh_from_db()
  350. # Updating an existing interface should not re-assign the MAC.
  351. self.assertIsNone(mac.assigned_object)
  352. class SyncCachedScopeFieldsSignalTestCase(TestCase):
  353. """
  354. Verify dcim.signals.sync_cached_scope_fields recomputes cached scope fields on
  355. Prefix, Cluster, and WirelessLAN when a Site or Location is modified.
  356. """
  357. def test_site_group_change_updates_prefix_cached_scope(self):
  358. group_a = SiteGroup.objects.create(name='Group A', slug='group-a')
  359. group_b = SiteGroup.objects.create(name='Group B', slug='group-b')
  360. site = Site.objects.create(name='Site', slug='site', group=group_a)
  361. prefix = Prefix.objects.create(
  362. prefix='10.0.0.0/24',
  363. scope_type=ContentType.objects.get_for_model(Site),
  364. scope_id=site.pk,
  365. )
  366. self.assertEqual(prefix._site_group, group_a)
  367. site.group = group_b
  368. site.save()
  369. prefix.refresh_from_db()
  370. self.assertEqual(prefix._site, site)
  371. self.assertEqual(prefix._site_group, group_b)
  372. def test_location_site_change_updates_prefix_cached_scope(self):
  373. site_a = Site.objects.create(name='Site A', slug='site-a')
  374. site_b = Site.objects.create(name='Site B', slug='site-b')
  375. location = Location.objects.create(name='Loc', slug='loc', site=site_a)
  376. prefix = Prefix.objects.create(
  377. prefix='10.0.0.0/24',
  378. scope_type=ContentType.objects.get_for_model(Location),
  379. scope_id=location.pk,
  380. )
  381. self.assertEqual(prefix._site, site_a)
  382. self.assertEqual(prefix._location, location)
  383. location.site = site_b
  384. location.save()
  385. prefix.refresh_from_db()
  386. self.assertEqual(prefix._location, location)
  387. self.assertEqual(prefix._site, site_b)
  388. def test_signal_updates_cluster_and_wirelesslan_cached_scope(self):
  389. # Lock down the explicit (Prefix, Cluster, WirelessLAN) tuple in the
  390. # signal by exercising Cluster and WirelessLAN alongside Prefix. If a
  391. # future change drops Cluster or WirelessLAN from that tuple, this test
  392. # will catch it.
  393. group_a = SiteGroup.objects.create(name='Group A', slug='group-a')
  394. group_b = SiteGroup.objects.create(name='Group B', slug='group-b')
  395. site = Site.objects.create(name='Site', slug='site', group=group_a)
  396. cluster_type = ClusterType.objects.create(name='CT', slug='ct')
  397. cluster = Cluster.objects.create(name='Cluster', type=cluster_type, scope=site)
  398. wireless_lan = WirelessLAN.objects.create(ssid='LAN', scope=site)
  399. self.assertEqual(cluster._site_group, group_a)
  400. self.assertEqual(wireless_lan._site_group, group_a)
  401. site.group = group_b
  402. site.save()
  403. cluster.refresh_from_db()
  404. wireless_lan.refresh_from_db()
  405. self.assertEqual(cluster._site_group, group_b)
  406. self.assertEqual(wireless_lan._site_group, group_b)
  407. def test_create_site_does_not_attempt_to_resync(self):
  408. # Should not raise — newly-created sites have nothing to sync.
  409. Site.objects.create(name='New Site', slug='new-site')
  410. class CableSignalDirectHandlerTestCase(SimpleTestCase):
  411. """
  412. Direct-call tests for dcim signal branches that are not reachable through normal
  413. model operations (raw=True is set only by Django's loaddata pathway).
  414. """
  415. def test_update_connected_endpoints_raw_import_is_a_no_op(self):
  416. cable = SimpleNamespace(_terminations_modified=True)
  417. logger = MagicMock()
  418. with (
  419. patch.object(signals.logging, 'getLogger', return_value=logger),
  420. patch.object(signals, 'CableTermination') as cabletermination_model,
  421. patch.object(signals, 'create_cablepaths') as create_cablepaths,
  422. patch.object(signals, 'rebuild_paths') as rebuild_paths,
  423. ):
  424. signals.update_connected_endpoints(instance=cable, created=True, raw=True)
  425. logger.debug.assert_called_once()
  426. cabletermination_model.objects.filter.assert_not_called()
  427. create_cablepaths.assert_not_called()
  428. rebuild_paths.assert_not_called()
  429. def test_update_mac_address_interface_raw_import_is_a_no_op(self):
  430. primary_mac = SimpleNamespace(save=MagicMock())
  431. interface = SimpleNamespace(primary_mac_address=primary_mac)
  432. signals.update_mac_address_interface(instance=interface, created=True, raw=True)
  433. primary_mac.save.assert_not_called()