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

Merge pull request #4521 from netbox-community/4388-cable-tracing

Fixes #4388: Improve connection endpoint detection
Jeremy Stretch 5 лет назад
Родитель
Сommit
8a3a5a8cb1

+ 1 - 1
netbox/dcim/api/views.py

@@ -48,7 +48,7 @@ class CableTraceMixin(object):
         # Initialize the path array
         # Initialize the path array
         path = []
         path = []
 
 
-        for near_end, cable, far_end in obj.trace():
+        for near_end, cable, far_end in obj.trace()[0]:
 
 
             # Serialize each object
             # Serialize each object
             serializer_a = get_serializer_for_model(near_end, prefix='Nested')
             serializer_a = get_serializer_for_model(near_end, prefix='Nested')

+ 9 - 0
netbox/dcim/exceptions.py

@@ -3,3 +3,12 @@ class LoopDetected(Exception):
     A loop has been detected while tracing a cable path.
     A loop has been detected while tracing a cable path.
     """
     """
     pass
     pass
+
+
+class CableTraceSplit(Exception):
+    """
+    A cable trace cannot be completed because a RearPort maps to multiple FrontPorts and
+    we don't know which one to follow.
+    """
+    def __init__(self, termination, *args, **kwargs):
+        self.termination = termination

+ 0 - 23
netbox/dcim/models/__init__.py

@@ -2205,26 +2205,3 @@ class Cable(ChangeLoggedModel):
         if self.termination_a is None:
         if self.termination_a is None:
             return
             return
         return COMPATIBLE_TERMINATION_TYPES[self.termination_a._meta.model_name]
         return COMPATIBLE_TERMINATION_TYPES[self.termination_a._meta.model_name]
-
-    def get_path_endpoints(self):
-        """
-        Traverse both ends of a cable path and return its connected endpoints. Note that one or both endpoints may be
-        None.
-        """
-        a_path = self.termination_b.trace()
-        b_path = self.termination_a.trace()
-
-        # Determine overall path status (connected or planned)
-        if self.status == CableStatusChoices.STATUS_CONNECTED:
-            path_status = True
-            for segment in a_path[1:] + b_path[1:]:
-                if segment[1] is None or segment[1].status != CableStatusChoices.STATUS_CONNECTED:
-                    path_status = False
-                    break
-        else:
-            path_status = False
-
-        a_endpoint = a_path[-1][2]
-        b_endpoint = b_path[-1][2]
-
-        return a_endpoint, b_endpoint, path_status

+ 34 - 9
netbox/dcim/models/device_components.py

@@ -10,6 +10,7 @@ from taggit.managers import TaggableManager
 
 
 from dcim.choices import *
 from dcim.choices import *
 from dcim.constants import *
 from dcim.constants import *
+from dcim.exceptions import CableTraceSplit
 from dcim.fields import MACAddressField
 from dcim.fields import MACAddressField
 from extras.models import ObjectChange, TaggedItem
 from extras.models import ObjectChange, TaggedItem
 from extras.utils import extras_features
 from extras.utils import extras_features
@@ -91,7 +92,13 @@ class CableTermination(models.Model):
 
 
     def trace(self):
     def trace(self):
         """
         """
-        Return a list representing a complete cable path, with each individual segment represented as a three-tuple:
+        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.
+
+        The path is a list representing a complete cable path, with each individual segment represented as a
+        three-tuple:
+
             [
             [
                 (termination A, cable, termination B),
                 (termination A, cable, termination B),
                 (termination C, cable, termination D),
                 (termination C, cable, termination D),
@@ -117,10 +124,7 @@ class CableTermination(models.Model):
 
 
                 # Can't map to a FrontPort without a position
                 # Can't map to a FrontPort without a position
                 if not position_stack:
                 if not position_stack:
-                    # TODO: This behavior is broken. We need a mechanism by which to return all FrontPorts mapped
-                    # to a given RearPort so that we can update end-to-end paths when a cable is created/deleted.
-                    # For now, we're maintaining the current behavior of tracing only to the first FrontPort.
-                    position_stack.append(1)
+                    raise CableTraceSplit(termination)
 
 
                 position = position_stack.pop()
                 position = position_stack.pop()
 
 
@@ -159,12 +163,12 @@ class CableTermination(models.Model):
             if not endpoint.cable:
             if not endpoint.cable:
                 path.append((endpoint, None, None))
                 path.append((endpoint, None, None))
                 logger.debug("No cable connected")
                 logger.debug("No cable connected")
-                return path
+                return path, None
 
 
             # Check for loops
             # Check for loops
             if endpoint.cable in [segment[1] for segment in path]:
             if endpoint.cable in [segment[1] for segment in path]:
                 logger.debug("Loop detected!")
                 logger.debug("Loop detected!")
-                return path
+                return path, None
 
 
             # Record the current segment in the path
             # Record the current segment in the path
             far_end = endpoint.get_cable_peer()
             far_end = endpoint.get_cable_peer()
@@ -174,9 +178,13 @@ class CableTermination(models.Model):
             ))
             ))
 
 
             # Get the peer port of the far end termination
             # Get the peer port of the far end termination
-            endpoint = get_peer_port(far_end)
+            try:
+                endpoint = get_peer_port(far_end)
+            except CableTraceSplit as e:
+                return path, e.termination.frontports.all()
+
             if endpoint is None:
             if endpoint is None:
-                return path
+                return path, None
 
 
     def get_cable_peer(self):
     def get_cable_peer(self):
         if self.cable is None:
         if self.cable is None:
@@ -186,6 +194,23 @@ class CableTermination(models.Model):
         if self._cabled_as_b.exists():
         if self._cabled_as_b.exists():
             return self.cable.termination_a
             return self.cable.termination_a
 
 
+    def get_path_endpoints(self):
+        """
+        Return all endpoints of paths which traverse this object.
+        """
+        endpoints = []
+
+        # Get the far end of the last path segment
+        path, split_ends = self.trace()
+        endpoint = path[-1][2]
+        if split_ends is not None:
+            for termination in split_ends:
+                endpoints.extend(termination.get_path_endpoints())
+        elif endpoint is not None:
+            endpoints.append(endpoint)
+
+        return endpoints
+
 
 
 #
 #
 # Console ports
 # Console ports

+ 31 - 20
netbox/dcim/signals.py

@@ -3,6 +3,7 @@ import logging
 from django.db.models.signals import post_save, pre_delete
 from django.db.models.signals import post_save, pre_delete
 from django.dispatch import receiver
 from django.dispatch import receiver
 
 
+from .choices import CableStatusChoices
 from .models import Cable, Device, VirtualChassis
 from .models import Cable, Device, VirtualChassis
 
 
 
 
@@ -48,16 +49,28 @@ def update_connected_endpoints(instance, **kwargs):
         instance.termination_b.cable = instance
         instance.termination_b.cable = instance
         instance.termination_b.save()
         instance.termination_b.save()
 
 
-    # Check if this Cable has formed a complete path. If so, update both endpoints.
-    endpoint_a, endpoint_b, path_status = instance.get_path_endpoints()
-    if getattr(endpoint_a, 'is_path_endpoint', False) and getattr(endpoint_b, 'is_path_endpoint', False):
-        logger.debug("Updating path endpoints: {} <---> {}".format(endpoint_a, endpoint_b))
-        endpoint_a.connected_endpoint = endpoint_b
-        endpoint_a.connection_status = path_status
-        endpoint_a.save()
-        endpoint_b.connected_endpoint = endpoint_a
-        endpoint_b.connection_status = path_status
-        endpoint_b.save()
+    # 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()
+        # Determine overall path status (connected or planned)
+        path_status = True
+        for segment in path:
+            if segment[1] is None or segment[1].status != CableStatusChoices.STATUS_CONNECTED:
+                path_status = False
+                break
+
+        endpoint_a = path[0][0]
+        endpoint_b = path[-1][2]
+
+        if getattr(endpoint_a, 'is_path_endpoint', False) and getattr(endpoint_b, 'is_path_endpoint', False):
+            logger.debug("Updating path endpoints: {} <---> {}".format(endpoint_a, endpoint_b))
+            endpoint_a.connected_endpoint = endpoint_b
+            endpoint_a.connection_status = path_status
+            endpoint_a.save()
+            endpoint_b.connected_endpoint = endpoint_a
+            endpoint_b.connection_status = path_status
+            endpoint_b.save()
 
 
 
 
 @receiver(pre_delete, sender=Cable)
 @receiver(pre_delete, sender=Cable)
@@ -67,7 +80,7 @@ def nullify_connected_endpoints(instance, **kwargs):
     """
     """
     logger = logging.getLogger('netbox.dcim.cable')
     logger = logging.getLogger('netbox.dcim.cable')
 
 
-    endpoint_a, endpoint_b, _ = instance.get_path_endpoints()
+    endpoints = instance.termination_a.get_path_endpoints() + instance.termination_b.get_path_endpoints()
 
 
     # Disassociate the Cable from its termination points
     # Disassociate the Cable from its termination points
     if instance.termination_a is not None:
     if instance.termination_a is not None:
@@ -79,12 +92,10 @@ def nullify_connected_endpoints(instance, **kwargs):
         instance.termination_b.cable = None
         instance.termination_b.cable = None
         instance.termination_b.save()
         instance.termination_b.save()
 
 
-    # If this Cable was part of a complete path, tear it down
-    if hasattr(endpoint_a, 'connected_endpoint') and hasattr(endpoint_b, 'connected_endpoint'):
-        logger.debug("Tearing down path ({} <---> {})".format(endpoint_a, endpoint_b))
-        endpoint_a.connected_endpoint = None
-        endpoint_a.connection_status = None
-        endpoint_a.save()
-        endpoint_b.connected_endpoint = None
-        endpoint_b.connection_status = None
-        endpoint_b.save()
+    # If this Cable was part of any complete end-to-end paths, tear them down.
+    for endpoint in endpoints:
+        logger.debug(f"Removing path information for {endpoint}")
+        if hasattr(endpoint, 'connected_endpoint'):
+            endpoint.connected_endpoint = None
+            endpoint.connection_status = None
+            endpoint.save()

+ 145 - 40
netbox/dcim/tests/test_models.py

@@ -549,12 +549,21 @@ class CablePathTestCase(TestCase):
         self.assertIsNone(endpoint_a.connection_status)
         self.assertIsNone(endpoint_a.connection_status)
         self.assertIsNone(endpoint_b.connection_status)
         self.assertIsNone(endpoint_b.connection_status)
 
 
-    def test_connection_via_patch(self):
-        """
-                     1               2               3
-        [Device 1] ----- [Panel 1] ----- [Panel 2] ----- [Device 2]
-             Iface1     FP1     RP1     RP1     FP1     Iface1
-
+    def test_connections_via_patch(self):
+        """
+        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       | FP1
+                          [Panel 1] ----- [Panel 2]
+                          FP2 |   RP1   RP1   | FP2
+              Iface1          |               |          Iface1
+        [Device 3] -----------+               +----------- [Device 4]
+                        4                           5
         """
         """
         # Create cables
         # Create cables
         cable1 = Cable(
         cable1 = Cable(
@@ -563,45 +572,78 @@ class CablePathTestCase(TestCase):
         )
         )
         cable1.save()
         cable1.save()
         cable2 = Cable(
         cable2 = 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')
+            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.save()
         cable2.save()
+
         cable3 = Cable(
         cable3 = Cable(
-            termination_a=FrontPort.objects.get(device__name='Panel 2', name='Front Port 1'),
-            termination_b=Interface.objects.get(device__name='Device 2', name='Interface 1')
+            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.save()
         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.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.save()
+
         # Retrieve endpoints
         # Retrieve endpoints
         endpoint_a = Interface.objects.get(device__name='Device 1', name='Interface 1')
         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_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
         # Validate connections
         self.assertEqual(endpoint_a.connected_endpoint, endpoint_b)
         self.assertEqual(endpoint_a.connected_endpoint, endpoint_b)
         self.assertEqual(endpoint_b.connected_endpoint, endpoint_a)
         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_a.connection_status)
         self.assertTrue(endpoint_b.connection_status)
         self.assertTrue(endpoint_b.connection_status)
+        self.assertTrue(endpoint_c.connection_status)
+        self.assertTrue(endpoint_d.connection_status)
 
 
-        # Delete cable 2
-        cable2.delete()
+        # Delete cable 3
+        cable3.delete()
 
 
         # Refresh endpoints
         # Refresh endpoints
         endpoint_a.refresh_from_db()
         endpoint_a.refresh_from_db()
         endpoint_b.refresh_from_db()
         endpoint_b.refresh_from_db()
+        endpoint_c.refresh_from_db()
+        endpoint_d.refresh_from_db()
 
 
         # Check that connections have been nullified
         # Check that connections have been nullified
         self.assertIsNone(endpoint_a.connected_endpoint)
         self.assertIsNone(endpoint_a.connected_endpoint)
         self.assertIsNone(endpoint_b.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_a.connection_status)
         self.assertIsNone(endpoint_b.connection_status)
         self.assertIsNone(endpoint_b.connection_status)
+        self.assertIsNone(endpoint_c.connection_status)
+        self.assertIsNone(endpoint_d.connection_status)
 
 
-    def test_connection_via_multiple_patches(self):
+    def test_connections_via_multiple_patches(self):
         """
         """
-                     1               2               3               4               5
-        [Device 1] ----- [Panel 1] ----- [Panel 2] ----- [Panel 3] ----- [Panel 4] ----- [Device 2]
-             Iface1     FP1     RP1     RP1     FP1     FP1     RP1     RP1     FP1     Iface1
+        Test two connections via patched rear ports:
+            Device 1 <---> Device 2
+            Device 3 <---> Device 4
 
 
+                        1                             2                             3
+        [Device 1] -----------+               +---------------+               +----------- [Device 2]
+              Iface1          |               |               |               |          Iface1
+                          FP1 |       4       | FP1       FP1 |       5       | FP1
+                          [Panel 1] ----- [Panel 2]       [Panel 3] ----- [Panel 4]
+                          FP2 |   RP1   RP1   | FP2       FP2 |   RP1   RP1   | FP2
+              Iface1          |               |               |               |          Iface1
+        [Device 3] -----------+               +---------------+               +----------- [Device 4]
+                        6                             7                             8
         """
         """
         # Create cables
         # Create cables
         cable1 = Cable(
         cable1 = Cable(
@@ -610,55 +652,94 @@ class CablePathTestCase(TestCase):
         )
         )
         cable1.save()
         cable1.save()
         cable2 = Cable(
         cable2 = 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')
+            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.save()
         cable2.save()
         cable3 = Cable(
         cable3 = 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')
+            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.save()
         cable3.save()
+
         cable4 = Cable(
         cable4 = 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')
+            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.save()
         cable4.save()
         cable5 = Cable(
         cable5 = 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')
+            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.save()
         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.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.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.save()
+
         # Retrieve endpoints
         # Retrieve endpoints
         endpoint_a = Interface.objects.get(device__name='Device 1', name='Interface 1')
         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_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
         # Validate connections
         self.assertEqual(endpoint_a.connected_endpoint, endpoint_b)
         self.assertEqual(endpoint_a.connected_endpoint, endpoint_b)
         self.assertEqual(endpoint_b.connected_endpoint, endpoint_a)
         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_a.connection_status)
         self.assertTrue(endpoint_b.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()
+        # Delete cables 4 and 5
+        cable4.delete()
+        cable5.delete()
 
 
         # Refresh endpoints
         # Refresh endpoints
         endpoint_a.refresh_from_db()
         endpoint_a.refresh_from_db()
         endpoint_b.refresh_from_db()
         endpoint_b.refresh_from_db()
+        endpoint_c.refresh_from_db()
+        endpoint_d.refresh_from_db()
 
 
         # Check that connections have been nullified
         # Check that connections have been nullified
         self.assertIsNone(endpoint_a.connected_endpoint)
         self.assertIsNone(endpoint_a.connected_endpoint)
         self.assertIsNone(endpoint_b.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_a.connection_status)
         self.assertIsNone(endpoint_b.connection_status)
         self.assertIsNone(endpoint_b.connection_status)
+        self.assertIsNone(endpoint_c.connection_status)
+        self.assertIsNone(endpoint_d.connection_status)
 
 
-    def test_connection_via_stacked_rear_ports(self):
+    def test_connections_via_nested_rear_ports(self):
         """
         """
-                     1               2               3               4               5
-        [Device 1] ----- [Panel 1] ----- [Panel 2] ----- [Panel 3] ----- [Panel 4] ----- [Device 2]
-             Iface1     FP1     RP1     FP1     RP1     RP1     FP1     RP1     FP1     Iface1
+        Test two connections via nested rear ports:
+            Device 1 <---> Device 2
+            Device 3 <---> Device 4
 
 
+                        1                                                           2
+        [Device 1] -----------+                                               +----------- [Device 2]
+              Iface1          |                                               |          Iface1
+                          FP1 |       3               4               5       | FP1
+                          [Panel 1] ----- [Panel 2] ----- [Panel 3] ----- [Panel 4]
+                          FP2 |   RP1   FP1       RP1   RP1       FP1   RP1   | FP2
+              Iface1          |                                               |          Iface1
+        [Device 3] -----------+                                               +----------- [Device 4]
+                        6                                                           7
         """
         """
         # Create cables
         # Create cables
         cable1 = Cable(
         cable1 = Cable(
@@ -667,48 +748,72 @@ class CablePathTestCase(TestCase):
         )
         )
         cable1.save()
         cable1.save()
         cable2 = Cable(
         cable2 = 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')
+            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.save()
         cable2.save()
+
         cable3 = Cable(
         cable3 = 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')
+            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.save()
         cable3.save()
         cable4 = Cable(
         cable4 = 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')
+            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.save()
         cable4.save()
         cable5 = Cable(
         cable5 = 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')
+            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.save()
         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.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.save()
+
         # Retrieve endpoints
         # Retrieve endpoints
         endpoint_a = Interface.objects.get(device__name='Device 1', name='Interface 1')
         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_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
         # Validate connections
         self.assertEqual(endpoint_a.connected_endpoint, endpoint_b)
         self.assertEqual(endpoint_a.connected_endpoint, endpoint_b)
         self.assertEqual(endpoint_b.connected_endpoint, endpoint_a)
         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_a.connection_status)
         self.assertTrue(endpoint_b.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()
+        # Delete cable 4
+        cable4.delete()
 
 
         # Refresh endpoints
         # Refresh endpoints
         endpoint_a.refresh_from_db()
         endpoint_a.refresh_from_db()
         endpoint_b.refresh_from_db()
         endpoint_b.refresh_from_db()
+        endpoint_c.refresh_from_db()
+        endpoint_d.refresh_from_db()
 
 
         # Check that connections have been nullified
         # Check that connections have been nullified
         self.assertIsNone(endpoint_a.connected_endpoint)
         self.assertIsNone(endpoint_a.connected_endpoint)
         self.assertIsNone(endpoint_b.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_a.connection_status)
         self.assertIsNone(endpoint_b.connection_status)
         self.assertIsNone(endpoint_b.connection_status)
+        self.assertIsNone(endpoint_c.connection_status)
+        self.assertIsNone(endpoint_d.connection_status)
 
 
     def test_connection_via_circuit(self):
     def test_connection_via_circuit(self):
         """
         """

+ 7 - 3
netbox/dcim/views.py

@@ -32,6 +32,7 @@ from virtualization.models import VirtualMachine
 from . import filters, forms, tables
 from . import filters, forms, tables
 from .choices import DeviceFaceChoices
 from .choices import DeviceFaceChoices
 from .constants import NONCONNECTABLE_IFACE_TYPES
 from .constants import NONCONNECTABLE_IFACE_TYPES
+from .exceptions import CableTraceSplit
 from .models import (
 from .models import (
     Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
     Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
     DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate,
     DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate,
@@ -2033,12 +2034,15 @@ class CableTraceView(PermissionRequiredMixin, View):
     def get(self, request, model, pk):
     def get(self, request, model, pk):
 
 
         obj = get_object_or_404(model, pk=pk)
         obj = get_object_or_404(model, pk=pk)
-        trace = obj.trace()
-        total_length = sum([entry[1]._abs_length for entry in trace if entry[1] and entry[1]._abs_length])
+        path, split_ends = obj.trace()
+        total_length = sum(
+            [entry[1]._abs_length for entry in path if entry[1] and entry[1]._abs_length]
+        )
 
 
         return render(request, 'dcim/cable_trace.html', {
         return render(request, 'dcim/cable_trace.html', {
             'obj': obj,
             'obj': obj,
-            'trace': trace,
+            'trace': path,
+            'split_ends': split_ends,
             'total_length': total_length,
             'total_length': total_length,
         })
         })
 
 

+ 45 - 1
netbox/templates/dcim/cable_trace.html

@@ -48,6 +48,50 @@
                 {% endif %}
                 {% endif %}
             </div>
             </div>
         </div>
         </div>
-        {% if not forloop.last %}<hr />{% endif %}
+        <hr />
     {% endfor %}
     {% endfor %}
+    <div class="row">
+        {% if split_ends %}
+            <div class="col-md-7 col-md-offset-3">
+                <div class="panel panel-warning">
+                    <div class="panel-heading">
+                        <strong><i class="fa fa-warning"></i> Trace Split</strong>
+                    </div>
+                    <div class="panel-body">
+                        There are multiple possible paths from this point. Select a port to continue.
+                    </div>
+                </div>
+                <div class="panel panel-default">
+                    <table class="panel-body table">
+                        <thead>
+                            <tr class="table-headings">
+                                <th>Port</th>
+                                <th>Connected</th>
+                                <th>Type</th>
+                                <th>Description</th>
+                            </tr>
+                        </thead>
+                        {% for termination in split_ends %}
+                            <tr>
+                                <td><a href="{% url 'dcim:frontport_trace' pk=termination.pk %}">{{ termination }}</a></td>
+                                <td>
+                                    {% if termination.cable %}
+                                        <i class="fa fa-check text-success" title="Yes"></i>
+                                    {% else %}
+                                        <i class="fa fa-times text-danger" title="No"></i>
+                                    {% endif %}
+                                </td>
+                                <td>{{ termination.get_type_display }}</td>
+                                <td>{{ termination.description|placeholder }}</td>
+                            </tr>
+                        {% endfor %}
+                    </table>
+                </div>
+            </div>
+        {% else %}
+            <div class="col-md-11 col-md-offset-1">
+                <h3 class="text-success text-center">Trace completed!</h3>
+            </div>
+        {% endif %}
+    </div>
 {% endblock %}
 {% endblock %}