Przeglądaj źródła

Fixes #22187: Fix cable trace when entering profiled cable via single-position pass-through ports (#22316)

Jeremy Stretch 1 dzień temu
rodzic
commit
877ba8bf9e
2 zmienionych plików z 109 dodań i 3 usunięć
  1. 12 3
      netbox/dcim/models/cables.py
  2. 97 0
      netbox/dcim/tests/test_cablepaths2.py

+ 12 - 3
netbox/dcim/models/cables.py

@@ -934,7 +934,17 @@ class CablePath(models.Model):
                 # Profile-based tracing
                 if links[0].profile:
                     cable_profile = links[0].profile_class()
-                    positions = position_stack.pop() if position_stack else [None]
+                    if position_stack:
+                        positions = position_stack.pop()
+                    else:
+                        # When the position stack is empty (e.g. the trace reached this
+                        # profiled cable after crossing single-position pass-through ports
+                        # which don't push onto the stack), derive positions from each
+                        # termination's own cable_positions — which were set by this
+                        # profiled cable when it was saved.
+                        positions = [
+                            pos for term in terminations for pos in (term.cable_positions or [])
+                        ]
                     remote_terminations = []
                     new_positions = []
 
@@ -951,9 +961,8 @@ class CablePath(models.Model):
                                     remaining[cp] -= 1
 
                     # Fallback for when positions don't match cable_positions
-                    # (e.g., empty position stack yielding [None])
                     if not term_position_pairs:
-                        term_position_pairs = [(terminations[0], pos) for pos in positions]
+                        term_position_pairs = [(terminations[0], pos) for pos in positions or [None]]
 
                     peer_results = cable_profile.get_peer_terminations(term_position_pairs)
                     seen = set()

+ 97 - 0
netbox/dcim/tests/test_cablepaths2.py

@@ -1857,6 +1857,103 @@ class CablePathTestCase(CablePathTestCase):
         )
         self.assertEqual(CablePath.objects.count(), 4)
 
+    def test_225_breakout_1c2p_2c1p_to_single_position_passthroughs(self):
+        """
+        Regression test for #22187: a 1C2P:2C1P breakout cable terminating on two
+        single-position FrontPorts (each mapping 1:1 to a single-position RearPort)
+        which are then connected to PathEndpoints via unprofiled cables.
+
+        [IF1] --C1 (1C2P:2C1P)-- [FP1] [RP1] --C2 (unprofiled)-- [IF2]
+                                 [FP2] [RP2] --C3 (unprofiled)-- [IF3]
+        """
+        interfaces = [
+            Interface.objects.create(device=self.device, name='Interface 1'),
+            Interface.objects.create(device=self.device, name='Interface 2'),
+            Interface.objects.create(device=self.device, name='Interface 3'),
+        ]
+        rear_ports = [
+            RearPort.objects.create(device=self.device, name='Rear Port 1'),
+            RearPort.objects.create(device=self.device, name='Rear Port 2'),
+        ]
+        front_ports = [
+            FrontPort.objects.create(device=self.device, name='Front Port 1'),
+            FrontPort.objects.create(device=self.device, name='Front Port 2'),
+        ]
+        PortMapping.objects.bulk_create([
+            PortMapping(
+                device=self.device,
+                front_port=front_ports[0],
+                front_port_position=1,
+                rear_port=rear_ports[0],
+                rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device,
+                front_port=front_ports[1],
+                front_port_position=1,
+                rear_port=rear_ports[1],
+                rear_port_position=1,
+            ),
+        ])
+
+        # Create cables
+        cable1 = Cable(
+            profile=CableProfileChoices.BREAKOUT_1C2P_2C1P,
+            a_terminations=[interfaces[0]],
+            b_terminations=[front_ports[0], front_ports[1]],
+        )
+        cable1.clean()
+        cable1.save()
+        cable2 = Cable(
+            a_terminations=[rear_ports[0]],
+            b_terminations=[interfaces[1]],
+        )
+        cable2.clean()
+        cable2.save()
+        cable3 = Cable(
+            a_terminations=[rear_ports[1]],
+            b_terminations=[interfaces[2]],
+        )
+        cable3.clean()
+        cable3.save()
+
+        # The breakout splits IF1's two positions into separate downstream cables,
+        # so the forward path has FP1/FP2, RP1/RP2, and the two unprofiled cables
+        # at the corresponding hops.
+        path1 = self.assertPathExists(
+            (
+                interfaces[0],
+                cable1,
+                [front_ports[0], front_ports[1]],
+                [rear_ports[0], rear_ports[1]],
+                [cable2, cable3],
+                [interfaces[1], interfaces[2]],
+            ),
+            is_complete=True,
+            is_active=True,
+        )
+        path2 = self.assertPathExists(
+            (interfaces[1], cable2, rear_ports[0], front_ports[0], cable1, interfaces[0]),
+            is_complete=True,
+            is_active=True,
+        )
+        path3 = self.assertPathExists(
+            (interfaces[2], cable3, rear_ports[1], front_ports[1], cable1, interfaces[0]),
+            is_complete=True,
+            is_active=True,
+        )
+        self.assertEqual(CablePath.objects.count(), 3)
+        for interface in interfaces:
+            interface.refresh_from_db()
+        self.assertPathIsSet(interfaces[0], path1)
+        self.assertPathIsSet(interfaces[1], path2)
+        self.assertPathIsSet(interfaces[2], path3)
+
+        # Test SVG generation from both directions
+        CableTraceSVG(interfaces[0]).render()
+        CableTraceSVG(interfaces[1]).render()
+        CableTraceSVG(interfaces[2]).render()
+
     def test_304_add_port_mapping_between_connected_ports(self):
         """
         [IF1] --C1-- [FP1] [RP1] --C2-- [IF2]