Просмотр исходного кода

Merge pull request #4706 from netbox-community/4604_check_position_stack

4708: more flexible checks on RearPort usage
Jeremy Stretch 5 лет назад
Родитель
Сommit
56ec4a6360

+ 8 - 5
netbox/dcim/models/__init__.py

@@ -2129,6 +2129,7 @@ class Cable(ChangeLoggedModel):
         return reverse('dcim:cable', args=[self.pk])
 
     def clean(self):
+        from circuits.models import CircuitTermination
 
         # Validate that termination A exists
         if not hasattr(self, 'termination_a_type'):
@@ -2191,19 +2192,21 @@ class Cable(ChangeLoggedModel):
                 f"Incompatible termination types: {self.termination_a_type} and {self.termination_b_type}"
             )
 
-        # A RearPort with multiple positions must be connected to a RearPort with an equal number of positions
+        # Check that a RearPort with multiple positions isn't connected to an endpoint
+        # or a RearPort with a different number of positions.
         for term_a, term_b in [
             (self.termination_a, self.termination_b),
             (self.termination_b, self.termination_a)
         ]:
             if isinstance(term_a, RearPort) and term_a.positions > 1:
-                if not isinstance(term_b, RearPort):
+                if not isinstance(term_b, (FrontPort, RearPort, CircuitTermination)):
                     raise ValidationError(
-                        "Rear ports with multiple positions may only be connected to other rear ports"
+                        "Rear ports with multiple positions may only be connected to other pass-through ports"
                     )
-                elif term_a.positions != term_b.positions:
+                if isinstance(term_b, RearPort) and term_b.positions > 1 and term_a.positions != term_b.positions:
                     raise ValidationError(
-                        f"{term_a} has {term_a.positions} position(s) but {term_b} has {term_b.positions}. "
+                        f"{term_a} of {term_a.device} has {term_a.positions} position(s) but "
+                        f"{term_b} of {term_b.device} has {term_b.positions}. "
                         f"Both terminations must have the same number of positions."
                     )
 

+ 33 - 26
netbox/dcim/models/device_components.py

@@ -89,16 +89,16 @@ class CableTermination(models.Model):
         object_id_field='termination_b_id'
     )
 
-    is_path_endpoint = True
-
     class Meta:
         abstract = True
 
     def trace(self):
         """
-        Return two items: the traceable portion of a cable path, and the termination points where it splits (if any).
-        This occurs when the trace is initiated from a midpoint along a path which traverses a RearPort. In cases where
-        the originating endpoint is unknown, it is not possible to know which corresponding FrontPort to follow.
+        Return three items: the traceable portion of a cable path, the termination points where it splits (if any), and
+        the remaining positions on the position stack (if any). Splits occur when the trace is initiated from a midpoint
+        along a path which traverses a RearPort. In cases where the originating endpoint is unknown, it is not possible
+        to know which corresponding FrontPort to follow. Remaining positions occur when tracing a path that traverses
+        a FrontPort without traversing a RearPort again.
 
         The path is a list representing a complete cable path, with each individual segment represented as a
         three-tuple:
@@ -118,26 +118,35 @@ class CableTermination(models.Model):
 
             # Map a front port to its corresponding rear port
             if isinstance(termination, FrontPort):
-                position_stack.append(termination.rear_port_position)
                 # Retrieve the corresponding RearPort from database to ensure we have an up-to-date instance
                 peer_port = RearPort.objects.get(pk=termination.rear_port.pk)
+
+                # Don't use the stack for RearPorts with a single position. Only remember the position at
+                # many-to-one points so we can select the correct FrontPort when we reach the corresponding
+                # one-to-many point.
+                if peer_port.positions > 1:
+                    position_stack.append(termination)
+
                 return peer_port
 
             # Map a rear port/position to its corresponding front port
             elif isinstance(termination, RearPort):
-
-                # Can't map to a FrontPort without a position if there are multiple options
-                if termination.positions > 1 and not position_stack:
-                    raise CableTraceSplit(termination)
-
-                # We can assume position 1 if the RearPort has only one position
-                position = position_stack.pop() if position_stack else 1
-
-                # Validate the position
-                if position not in range(1, termination.positions + 1):
-                    raise Exception("Invalid position for {} ({} positions): {})".format(
-                        termination, termination.positions, position
-                    ))
+                if termination.positions > 1:
+                    # Can't map to a FrontPort without a position if there are multiple options
+                    if not position_stack:
+                        raise CableTraceSplit(termination)
+
+                    front_port = position_stack.pop()
+                    position = front_port.rear_port_position
+
+                    # Validate the position
+                    if position not in range(1, termination.positions + 1):
+                        raise Exception("Invalid position for {} ({} positions): {})".format(
+                            termination, termination.positions, position
+                        ))
+                else:
+                    # Don't use the stack for RearPorts with a single position. The only possible position is 1.
+                    position = 1
 
                 try:
                     peer_port = FrontPort.objects.get(
@@ -168,12 +177,12 @@ class CableTermination(models.Model):
             if not endpoint.cable:
                 path.append((endpoint, None, None))
                 logger.debug("No cable connected")
-                return path, None
+                return path, None, position_stack
 
             # Check for loops
             if endpoint.cable in [segment[1] for segment in path]:
                 logger.debug("Loop detected!")
-                return path, None
+                return path, None, position_stack
 
             # Record the current segment in the path
             far_end = endpoint.get_cable_peer()
@@ -186,10 +195,10 @@ class CableTermination(models.Model):
             try:
                 endpoint = get_peer_port(far_end)
             except CableTraceSplit as e:
-                return path, e.termination.frontports.all()
+                return path, e.termination.frontports.all(), position_stack
 
             if endpoint is None:
-                return path, None
+                return path, None, position_stack
 
     def get_cable_peer(self):
         if self.cable is None:
@@ -206,7 +215,7 @@ class CableTermination(models.Model):
         endpoints = []
 
         # Get the far end of the last path segment
-        path, split_ends = self.trace()
+        path, split_ends, position_stack = self.trace()
         endpoint = path[-1][2]
         if split_ends is not None:
             for termination in split_ends:
@@ -872,7 +881,6 @@ class FrontPort(CableTermination, ComponentModel):
     tags = TaggableManager(through=TaggedItem)
 
     csv_headers = ['device', 'name', 'type', 'rear_port', 'rear_port_position', 'description']
-    is_path_endpoint = False
 
     class Meta:
         ordering = ('device', '_name')
@@ -937,7 +945,6 @@ class RearPort(CableTermination, ComponentModel):
     tags = TaggableManager(through=TaggedItem)
 
     csv_headers = ['device', 'name', 'type', 'positions', 'description']
-    is_path_endpoint = False
 
     class Meta:
         ordering = ('device', '_name')

+ 6 - 4
netbox/dcim/signals.py

@@ -4,7 +4,7 @@ from django.db.models.signals import post_save, pre_delete
 from django.dispatch import receiver
 
 from .choices import CableStatusChoices
-from .models import Cable, Device, VirtualChassis
+from .models import Cable, CableTermination, Device, FrontPort, RearPort, VirtualChassis
 
 
 @receiver(post_save, sender=VirtualChassis)
@@ -52,7 +52,7 @@ def update_connected_endpoints(instance, **kwargs):
     # Update any endpoints for this Cable.
     endpoints = instance.termination_a.get_path_endpoints() + instance.termination_b.get_path_endpoints()
     for endpoint in endpoints:
-        path, split_ends = endpoint.trace()
+        path, split_ends, position_stack = endpoint.trace()
         # Determine overall path status (connected or planned)
         path_status = True
         for segment in path:
@@ -61,9 +61,11 @@ def update_connected_endpoints(instance, **kwargs):
                 break
 
         endpoint_a = path[0][0]
-        endpoint_b = path[-1][2]
+        endpoint_b = path[-1][2] if not split_ends and not position_stack else None
 
-        if getattr(endpoint_a, 'is_path_endpoint', False) and getattr(endpoint_b, 'is_path_endpoint', False):
+        # Patch panel ports are not connected endpoints, all other cable terminations are
+        if isinstance(endpoint_a, CableTermination) and not isinstance(endpoint_a, (FrontPort, RearPort)) and \
+                isinstance(endpoint_b, CableTermination) and not isinstance(endpoint_b, (FrontPort, RearPort)):
             logger.debug("Updating path endpoints: {} <---> {}".format(endpoint_a, endpoint_b))
             endpoint_a.connected_endpoint = endpoint_b
             endpoint_a.connection_status = path_status

+ 254 - 19
netbox/dcim/tests/test_models.py

@@ -363,6 +363,7 @@ class CableTestCase(TestCase):
         )
         self.interface1 = Interface.objects.create(device=self.device1, name='eth0')
         self.interface2 = Interface.objects.create(device=self.device2, name='eth0')
+        self.interface3 = Interface.objects.create(device=self.device2, name='eth1')
         self.cable = Cable(termination_a=self.interface1, termination_b=self.interface2)
         self.cable.save()
 
@@ -370,10 +371,27 @@ class CableTestCase(TestCase):
         self.patch_pannel = Device.objects.create(
             device_type=devicetype, device_role=devicerole, name='TestPatchPannel', site=site
         )
-        self.rear_port = RearPort.objects.create(device=self.patch_pannel, name='R1', type=1000)
-        self.front_port = FrontPort.objects.create(
-            device=self.patch_pannel, name='F1', type=1000, rear_port=self.rear_port
+        self.rear_port1 = RearPort.objects.create(device=self.patch_pannel, name='RP1', type='8p8c')
+        self.front_port1 = FrontPort.objects.create(
+            device=self.patch_pannel, name='FP1', type='8p8c', rear_port=self.rear_port1, rear_port_position=1
         )
+        self.rear_port2 = RearPort.objects.create(device=self.patch_pannel, name='RP2', type='8p8c', positions=2)
+        self.front_port2 = FrontPort.objects.create(
+            device=self.patch_pannel, name='FP2', type='8p8c', rear_port=self.rear_port2, rear_port_position=1
+        )
+        self.rear_port3 = RearPort.objects.create(device=self.patch_pannel, name='RP3', type='8p8c', positions=3)
+        self.front_port3 = FrontPort.objects.create(
+            device=self.patch_pannel, name='FP3', type='8p8c', rear_port=self.rear_port3, rear_port_position=1
+        )
+        self.rear_port4 = RearPort.objects.create(device=self.patch_pannel, name='RP4', type='8p8c', positions=3)
+        self.front_port4 = FrontPort.objects.create(
+            device=self.patch_pannel, name='FP4', type='8p8c', rear_port=self.rear_port4, rear_port_position=1
+        )
+        self.provider = Provider.objects.create(name='Provider 1', slug='provider-1')
+        self.circuittype = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1')
+        self.circuit = Circuit.objects.create(provider=self.provider, type=self.circuittype, cid='1')
+        self.circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, site=site, term_side='A', port_speed=1000)
+        self.circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit, site=site, term_side='Z', port_speed=1000)
 
     def test_cable_creation(self):
         """
@@ -405,7 +423,7 @@ class CableTestCase(TestCase):
         cable = Cable.objects.filter(pk=self.cable.pk).first()
         self.assertIsNone(cable)
 
-    def test_cable_validates_compatibale_types(self):
+    def test_cable_validates_compatible_types(self):
         """
         The clean method should have a check to ensure only compatible port types can be connected by a cable
         """
@@ -426,7 +444,7 @@ class CableTestCase(TestCase):
         """
         A cable cannot connect a front port to its corresponding rear port
         """
-        cable = Cable(termination_a=self.front_port, termination_b=self.rear_port)
+        cable = Cable(termination_a=self.front_port1, termination_b=self.rear_port1)
         with self.assertRaises(ValidationError):
             cable.clean()
 
@@ -439,7 +457,94 @@ class CableTestCase(TestCase):
         with self.assertRaises(ValidationError):
             cable.clean()
 
-    def test_cable_cannot_terminate_to_a_virtual_inteface(self):
+    def test_connection_via_single_position_rearport(self):
+        """
+        A RearPort with one position can be connected to anything.
+
+            [CableTermination X]---[RP(pos=1) FP]---[CableTermination Y]
+
+        is allowed anywhere
+
+            [CableTermination X]---[CableTermination Y]
+
+        is allowed.
+
+        A RearPort with multiple positions may not be directly connected to a path endpoint or another RearPort
+        with a different number of positions. RearPorts with a single position on the other hand may be connected
+        to such CableTerminations. Check that this is indeed allowed.
+        """
+        # Connecting a single-position RearPort to a multi-position RearPort is ok
+        Cable(termination_a=self.rear_port1, termination_b=self.rear_port2).full_clean()
+
+        # Connecting a single-position RearPort to an Interface is ok
+        Cable(termination_a=self.rear_port1, termination_b=self.interface3).full_clean()
+
+        # Connecting a single-position RearPort to a CircuitTermination is ok
+        Cable(termination_a=self.rear_port1, termination_b=self.circuittermination1).full_clean()
+
+    def test_connection_via_multi_position_rearport(self):
+        """
+        A RearPort with multiple positions may not be directly connected to a path endpoint or another RearPort
+        with a different number of positions.
+
+        The following scenario's are allowed (with x>1):
+
+            ~----------+   +---------~
+                       |   |
+             RP2(pos=x)|---|RP(pos=x)
+                       |   |
+            ~----------+   +---------~
+
+            ~----------+   +---------~
+                       |   |
+             RP2(pos=x)|---|RP(pos=1)
+                       |   |
+            ~----------+   +---------~
+
+            ~----------+   +------------------~
+                       |   |
+             RP2(pos=x)|---|CircuitTermination
+                       |   |
+            ~----------+   +------------------~
+
+        These scenarios are NOT allowed (with x>1):
+
+            ~----------+   +----------~
+                       |   |
+             RP2(pos=x)|---|RP(pos!=x)
+                       |   |
+            ~----------+   +----------~
+
+            ~----------+   +----------~
+                       |   |
+             RP2(pos=x)|---|Interface
+                       |   |
+            ~----------+   +----------~
+
+        These scenarios are tested in this order below.
+        """
+        # Connecting a multi-position RearPort to another RearPort with the same number of positions is ok
+        Cable(termination_a=self.rear_port3, termination_b=self.rear_port4).full_clean()
+
+        # Connecting a multi-position RearPort to a single-position RearPort is ok
+        Cable(termination_a=self.rear_port2, termination_b=self.rear_port1).full_clean()
+
+        # Connecting a multi-position RearPort to a CircuitTermination is ok
+        Cable(termination_a=self.rear_port2, termination_b=self.circuittermination1).full_clean()
+
+        with self.assertRaises(
+            ValidationError,
+                msg='Connecting a 2-position RearPort to a 3-position RearPort should fail'
+        ):
+            Cable(termination_a=self.rear_port2, termination_b=self.rear_port3).full_clean()
+
+        with self.assertRaises(
+            ValidationError,
+                msg='Connecting a multi-position RearPort to an Interface should fail'
+        ):
+            Cable(termination_a=self.rear_port2, termination_b=self.interface3).full_clean()
+
+    def test_cable_cannot_terminate_to_a_virtual_interface(self):
         """
         A cable cannot terminate to a virtual interface
         """
@@ -448,7 +553,7 @@ class CableTestCase(TestCase):
         with self.assertRaises(ValidationError):
             cable.clean()
 
-    def test_cable_cannot_terminate_to_a_wireless_inteface(self):
+    def test_cable_cannot_terminate_to_a_wireless_interface(self):
         """
         A cable cannot terminate to a wireless interface
         """
@@ -501,9 +606,13 @@ class CablePathTestCase(TestCase):
             Device(device_type=devicetype, device_role=devicerole, name='Panel 2', site=site),
             Device(device_type=devicetype, device_role=devicerole, name='Panel 3', site=site),
             Device(device_type=devicetype, device_role=devicerole, name='Panel 4', site=site),
+            Device(device_type=devicetype, device_role=devicerole, name='Panel 5', site=site),
+            Device(device_type=devicetype, device_role=devicerole, name='Panel 6', site=site),
         )
         Device.objects.bulk_create(patch_panels)
-        for patch_panel in patch_panels:
+
+        # Create patch panels with 4 positions
+        for patch_panel in patch_panels[:4]:
             rearport = RearPort.objects.create(device=patch_panel, name='Rear Port 1', positions=4, type=PortTypeChoices.TYPE_8P8C)
             FrontPort.objects.bulk_create((
                 FrontPort(device=patch_panel, name='Front Port 1', rear_port=rearport, rear_port_position=1, type=PortTypeChoices.TYPE_8P8C),
@@ -512,6 +621,11 @@ class CablePathTestCase(TestCase):
                 FrontPort(device=patch_panel, name='Front Port 4', rear_port=rearport, rear_port_position=4, type=PortTypeChoices.TYPE_8P8C),
             ))
 
+        # Create 1-on-1 patch panels
+        for patch_panel in patch_panels[4:]:
+            rearport = RearPort.objects.create(device=patch_panel, name='Rear Port 1', positions=1, type=PortTypeChoices.TYPE_8P8C)
+            FrontPort.objects.create(device=patch_panel, name='Front Port 1', rear_port=rearport, rear_port_position=1, type=PortTypeChoices.TYPE_8P8C)
+
     def test_direct_connection(self):
         """
         Test a direct connection between two interfaces.
@@ -524,6 +638,7 @@ class CablePathTestCase(TestCase):
             termination_a=Interface.objects.get(device__name='Device 1', name='Interface 1'),
             termination_b=Interface.objects.get(device__name='Device 2', name='Interface 1')
         )
+        cable.full_clean()
         cable.save()
 
         # Retrieve endpoints
@@ -551,22 +666,25 @@ class CablePathTestCase(TestCase):
 
     def test_connection_via_single_rear_port(self):
         """
-        Test a connection which passes through a single front/rear port pair.
+        Test a connection which passes through a rear port with exactly one front port.
 
                      1               2
-        [Device 1] ----- [Panel 1] ----- [Device 2]
+        [Device 1] ----- [Panel 5] ----- [Device 2]
              Iface1     FP1     RP1     Iface1
         """
-        # Create cables
+        # Create cables (FP first, RP second)
         cable1 = Cable(
             termination_a=Interface.objects.get(device__name='Device 1', name='Interface 1'),
-            termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 1')
+            termination_b=FrontPort.objects.get(device__name='Panel 5', name='Front Port 1')
         )
+        cable1.full_clean()
         cable1.save()
         cable2 = Cable(
-            termination_b=RearPort.objects.get(device__name='Panel 1', name='Rear Port 1'),
-            termination_a=Interface.objects.get(device__name='Device 2', name='Interface 1')
+            termination_a=RearPort.objects.get(device__name='Panel 5', name='Rear Port 1'),
+            termination_b=Interface.objects.get(device__name='Device 2', name='Interface 1')
         )
+        self.assertEqual(cable2.termination_a.positions, 1)  # Sanity check
+        cable2.full_clean()
         cable2.save()
 
         # Retrieve endpoints
@@ -592,6 +710,97 @@ class CablePathTestCase(TestCase):
         self.assertIsNone(endpoint_a.connection_status)
         self.assertIsNone(endpoint_b.connection_status)
 
+    def test_connections_via_nested_single_position_rearport(self):
+        """
+        Test a connection which passes through a single front/rear port pair between two multi-position rear ports.
+
+        Test two connections via patched rear ports:
+            Device 1 <---> Device 2
+            Device 3 <---> Device 4
+
+                        1                                           2
+        [Device 1] -----------+                               +----------- [Device 2]
+              Iface1          |                               |          Iface1
+                          FP1 |       3               4       | FP1
+                          [Panel 1] ----- [Panel 5] ----- [Panel 2]
+                          FP2 |  RP1     RP1     FP1     RP1  | FP2
+              Iface1          |                               |          Iface1
+        [Device 3] -----------+                               +----------- [Device 4]
+                        5                                           6
+        """
+        # Create cables (Panel 5 RP first, FP second)
+        cable1 = Cable(
+            termination_a=Interface.objects.get(device__name='Device 1', name='Interface 1'),
+            termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 1')
+        )
+        cable1.full_clean()
+        cable1.save()
+        cable2 = Cable(
+            termination_b=FrontPort.objects.get(device__name='Panel 2', name='Front Port 1'),
+            termination_a=Interface.objects.get(device__name='Device 2', name='Interface 1')
+        )
+        cable2.full_clean()
+        cable2.save()
+        cable3 = Cable(
+            termination_b=RearPort.objects.get(device__name='Panel 1', name='Rear Port 1'),
+            termination_a=RearPort.objects.get(device__name='Panel 5', name='Rear Port 1')
+        )
+        cable3.full_clean()
+        cable3.save()
+        cable4 = Cable(
+            termination_b=FrontPort.objects.get(device__name='Panel 5', name='Front Port 1'),
+            termination_a=RearPort.objects.get(device__name='Panel 2', name='Rear Port 1')
+        )
+        cable4.full_clean()
+        cable4.save()
+        cable5 = Cable(
+            termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 2'),
+            termination_a=Interface.objects.get(device__name='Device 3', name='Interface 1')
+        )
+        cable5.full_clean()
+        cable5.save()
+        cable6 = Cable(
+            termination_b=FrontPort.objects.get(device__name='Panel 2', name='Front Port 2'),
+            termination_a=Interface.objects.get(device__name='Device 4', name='Interface 1')
+        )
+        cable6.full_clean()
+        cable6.save()
+
+        # Retrieve endpoints
+        endpoint_a = Interface.objects.get(device__name='Device 1', name='Interface 1')
+        endpoint_b = Interface.objects.get(device__name='Device 2', name='Interface 1')
+        endpoint_c = Interface.objects.get(device__name='Device 3', name='Interface 1')
+        endpoint_d = Interface.objects.get(device__name='Device 4', name='Interface 1')
+
+        # Validate connections
+        self.assertEqual(endpoint_a.connected_endpoint, endpoint_b)
+        self.assertEqual(endpoint_b.connected_endpoint, endpoint_a)
+        self.assertEqual(endpoint_c.connected_endpoint, endpoint_d)
+        self.assertEqual(endpoint_d.connected_endpoint, endpoint_c)
+        self.assertTrue(endpoint_a.connection_status)
+        self.assertTrue(endpoint_b.connection_status)
+        self.assertTrue(endpoint_c.connection_status)
+        self.assertTrue(endpoint_d.connection_status)
+
+        # Delete cable 3
+        cable3.delete()
+
+        # Refresh endpoints
+        endpoint_a.refresh_from_db()
+        endpoint_b.refresh_from_db()
+        endpoint_c.refresh_from_db()
+        endpoint_d.refresh_from_db()
+
+        # Check that connections have been nullified
+        self.assertIsNone(endpoint_a.connected_endpoint)
+        self.assertIsNone(endpoint_b.connected_endpoint)
+        self.assertIsNone(endpoint_c.connected_endpoint)
+        self.assertIsNone(endpoint_d.connected_endpoint)
+        self.assertIsNone(endpoint_a.connection_status)
+        self.assertIsNone(endpoint_b.connection_status)
+        self.assertIsNone(endpoint_c.connection_status)
+        self.assertIsNone(endpoint_d.connection_status)
+
     def test_connections_via_patch(self):
         """
         Test two connections via patched rear ports:
@@ -613,28 +822,33 @@ class CablePathTestCase(TestCase):
             termination_a=Interface.objects.get(device__name='Device 1', name='Interface 1'),
             termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 1')
         )
+        cable1.full_clean()
         cable1.save()
         cable2 = Cable(
             termination_a=Interface.objects.get(device__name='Device 2', name='Interface 1'),
             termination_b=FrontPort.objects.get(device__name='Panel 2', name='Front Port 1')
         )
+        cable2.full_clean()
         cable2.save()
 
         cable3 = Cable(
             termination_a=RearPort.objects.get(device__name='Panel 1', name='Rear Port 1'),
             termination_b=RearPort.objects.get(device__name='Panel 2', name='Rear Port 1')
         )
+        cable3.full_clean()
         cable3.save()
 
         cable4 = Cable(
             termination_a=Interface.objects.get(device__name='Device 3', name='Interface 1'),
             termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 2')
         )
+        cable4.full_clean()
         cable4.save()
         cable5 = Cable(
             termination_a=Interface.objects.get(device__name='Device 4', name='Interface 1'),
             termination_b=FrontPort.objects.get(device__name='Panel 2', name='Front Port 2')
         )
+        cable5.full_clean()
         cable5.save()
 
         # Retrieve endpoints
@@ -693,43 +907,51 @@ class CablePathTestCase(TestCase):
             termination_a=Interface.objects.get(device__name='Device 1', name='Interface 1'),
             termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 1')
         )
+        cable1.full_clean()
         cable1.save()
         cable2 = Cable(
             termination_a=FrontPort.objects.get(device__name='Panel 2', name='Front Port 1'),
             termination_b=FrontPort.objects.get(device__name='Panel 3', name='Front Port 1')
         )
+        cable2.full_clean()
         cable2.save()
         cable3 = Cable(
             termination_a=FrontPort.objects.get(device__name='Panel 4', name='Front Port 1'),
             termination_b=Interface.objects.get(device__name='Device 2', name='Interface 1')
         )
+        cable3.full_clean()
         cable3.save()
 
         cable4 = Cable(
             termination_a=RearPort.objects.get(device__name='Panel 1', name='Rear Port 1'),
             termination_b=RearPort.objects.get(device__name='Panel 2', name='Rear Port 1')
         )
+        cable4.full_clean()
         cable4.save()
         cable5 = Cable(
             termination_a=RearPort.objects.get(device__name='Panel 3', name='Rear Port 1'),
             termination_b=RearPort.objects.get(device__name='Panel 4', name='Rear Port 1')
         )
+        cable5.full_clean()
         cable5.save()
 
         cable6 = Cable(
             termination_a=Interface.objects.get(device__name='Device 3', name='Interface 1'),
             termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 2')
         )
+        cable6.full_clean()
         cable6.save()
         cable7 = Cable(
             termination_a=FrontPort.objects.get(device__name='Panel 2', name='Front Port 2'),
             termination_b=FrontPort.objects.get(device__name='Panel 3', name='Front Port 2')
         )
+        cable7.full_clean()
         cable7.save()
         cable8 = Cable(
             termination_a=FrontPort.objects.get(device__name='Panel 4', name='Front Port 2'),
             termination_b=Interface.objects.get(device__name='Device 4', name='Interface 1')
         )
+        cable8.full_clean()
         cable8.save()
 
         # Retrieve endpoints
@@ -789,38 +1011,45 @@ class CablePathTestCase(TestCase):
             termination_a=Interface.objects.get(device__name='Device 1', name='Interface 1'),
             termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 1')
         )
+        cable1.full_clean()
         cable1.save()
         cable2 = Cable(
             termination_a=FrontPort.objects.get(device__name='Panel 4', name='Front Port 1'),
             termination_b=Interface.objects.get(device__name='Device 2', name='Interface 1')
         )
+        cable2.full_clean()
         cable2.save()
 
         cable3 = Cable(
             termination_a=RearPort.objects.get(device__name='Panel 1', name='Rear Port 1'),
             termination_b=FrontPort.objects.get(device__name='Panel 2', name='Front Port 1')
         )
+        cable3.full_clean()
         cable3.save()
         cable4 = Cable(
             termination_a=RearPort.objects.get(device__name='Panel 2', name='Rear Port 1'),
             termination_b=RearPort.objects.get(device__name='Panel 3', name='Rear Port 1')
         )
+        cable4.full_clean()
         cable4.save()
         cable5 = Cable(
             termination_a=FrontPort.objects.get(device__name='Panel 3', name='Front Port 1'),
             termination_b=RearPort.objects.get(device__name='Panel 4', name='Rear Port 1')
         )
+        cable5.full_clean()
         cable5.save()
 
         cable6 = Cable(
             termination_a=Interface.objects.get(device__name='Device 3', name='Interface 1'),
             termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 2')
         )
+        cable6.full_clean()
         cable6.save()
         cable7 = Cable(
             termination_a=FrontPort.objects.get(device__name='Panel 4', name='Front Port 2'),
             termination_b=Interface.objects.get(device__name='Device 4', name='Interface 1')
         )
+        cable7.full_clean()
         cable7.save()
 
         # Retrieve endpoints
@@ -870,11 +1099,13 @@ class CablePathTestCase(TestCase):
             termination_a=Interface.objects.get(device__name='Device 1', name='Interface 1'),
             termination_b=CircuitTermination.objects.get(term_side='A')
         )
+        cable1.full_clean()
         cable1.save()
         cable2 = Cable(
             termination_a=CircuitTermination.objects.get(term_side='Z'),
             termination_b=Interface.objects.get(device__name='Device 2', name='Interface 1')
         )
+        cable2.full_clean()
         cable2.save()
 
         # Retrieve endpoints
@@ -903,30 +1134,34 @@ class CablePathTestCase(TestCase):
     def test_connection_via_patched_circuit(self):
         """
                      1               2               3               4
-        [Device 1] ----- [Panel 1] ----- [Circuit] ----- [Panel 2] ----- [Device 2]
+        [Device 1] ----- [Panel 5] ----- [Circuit] ----- [Panel 6] ----- [Device 2]
              Iface1     FP1     RP1     A         Z     RP1     FP1     Iface1
 
         """
         # Create cables
         cable1 = Cable(
             termination_a=Interface.objects.get(device__name='Device 1', name='Interface 1'),
-            termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 1')
+            termination_b=FrontPort.objects.get(device__name='Panel 5', name='Front Port 1')
         )
+        cable1.full_clean()
         cable1.save()
         cable2 = Cable(
-            termination_a=RearPort.objects.get(device__name='Panel 1', name='Rear Port 1'),
+            termination_a=RearPort.objects.get(device__name='Panel 5', name='Rear Port 1'),
             termination_b=CircuitTermination.objects.get(term_side='A')
         )
+        cable2.full_clean()
         cable2.save()
         cable3 = Cable(
             termination_a=CircuitTermination.objects.get(term_side='Z'),
-            termination_b=RearPort.objects.get(device__name='Panel 2', name='Rear Port 1')
+            termination_b=RearPort.objects.get(device__name='Panel 6', name='Rear Port 1')
         )
+        cable3.full_clean()
         cable3.save()
         cable4 = Cable(
-            termination_a=FrontPort.objects.get(device__name='Panel 2', name='Front Port 1'),
+            termination_a=FrontPort.objects.get(device__name='Panel 6', name='Front Port 1'),
             termination_b=Interface.objects.get(device__name='Device 2', name='Interface 1')
         )
+        cable4.full_clean()
         cable4.save()
 
         # Retrieve endpoints

+ 2 - 1
netbox/dcim/views.py

@@ -2057,7 +2057,7 @@ class CableTraceView(PermissionRequiredMixin, View):
     def get(self, request, model, pk):
 
         obj = get_object_or_404(model, pk=pk)
-        path, split_ends = obj.trace()
+        path, split_ends, position_stack = obj.trace()
         total_length = sum(
             [entry[1]._abs_length for entry in path if entry[1] and entry[1]._abs_length]
         )
@@ -2066,6 +2066,7 @@ class CableTraceView(PermissionRequiredMixin, View):
             'obj': obj,
             'trace': path,
             'split_ends': split_ends,
+            'position_stack': position_stack,
             'total_length': total_length,
         })
 

+ 10 - 0
netbox/templates/dcim/cable_trace.html

@@ -88,6 +88,16 @@
                     </table>
                 </div>
             </div>
+        {% elif position_stack %}
+            <div class="col-md-11 col-md-offset-1">
+                <h3 class="text-warning text-center">
+                    {% with last_position=position_stack|last %}
+                        Trace completed, but there is no Front Port corresponding to
+                        <a href="{{ last_position.device.get_absolute_url }}">{{ last_position.device }}</a> {{ last_position }}.<br>
+                        Therefore no end-to-end connection can be established.
+                    {% endwith %}
+                </h3>
+            </div>
         {% else %}
             <div class="col-md-11 col-md-offset-1">
                 <h3 class="text-success text-center">Trace completed!</h3>