Repository: netbox-community/netbox
Pull request: 4708: more flexible checks on RearPort usage
### Fixes: #4708

Alternative solution to #4708 that allows users to use RearPort in more complex ways without causing an IntegrityError with connected endpoints.

This implementation changes `trace()` to return the `position_stack` to the caller, which allows the caller to determine whether the returned path is end-to-end (nothing left on the stack) or whether it went through a FrontPort->RearPort without going through the corresponding RearPort->FrontPort (stack is not empty).

Adapted the `update_connected_endpoints` signal handler to consider an endpoint unconnected when the stack is not empty. This prevents the IntegrityError from occurring as connected endpoints are now only established for one-to-one paths.

Problem statement:
ValidationError when connecting multiple interfaces to one via a multi-position rear port
### Environment
* Python version: 3.7.3
* NetBox version: 2.8.5

### Steps to Reproduce
1. Disable any installed plugins by commenting out the `PLUGINS` setting in
   `configuration.py`.
2. Create three devices (A, B, and C) each with at least one non-connected interface.
3. Create a device (X) with two front ports mapped to a single rear port with 2 positions.
4. Connect interface A to the first front port of X.
5. Connect interface B to the second front port of X.
6. Create a device (Y) with one rear port with one position mapped to a single front port
7. Connect interface C to the rear port of Y.
8. Connect the rear port of X to the front port of Y

Alternative that should produce the same result:
7. Connect interface C to the front port of Y.
8. Connect the rear port of X to the rear port of Y

### Expected Behaviour
No errors expected. When this would succeed, I would expect the connected endpoints of all three devices to be empty, as there is no single endpoint for the interface on device C.

### Observed Behavior
In step 8 you get ValidationError "Rear ports with multiple positions may only be connected to other rear ports". 

In the alternative version the ValidationError is "RP has 2 position(s) but RP has 1. Both terminations must have the same number of positions.".

Both are not expected, as the 1-on-1 RearPort/FrontPort is basically a cable extender.

Hints from issue discussion:

Commit message: Update tests for cables

Patch to review:
diff --git a/netbox/dcim/models/__init__.py b/netbox/dcim/models/__init__.py
index 1f647811932..6a0b6c08765 100644
--- a/netbox/dcim/models/__init__.py
+++ b/netbox/dcim/models/__init__.py
@@ -2191,19 +2191,20 @@ def clean(self):
                 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 isn't connected to something silly
         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 term_b.is_path_endpoint:
                     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."
                     )
 
diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py
index 4005d41a4bd..d344546efad 100644
--- a/netbox/dcim/models/device_components.py
+++ b/netbox/dcim/models/device_components.py
@@ -93,9 +93,11 @@ class Meta:
 
     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:
@@ -115,26 +117,32 @@ def get_peer_port(termination):
 
             # 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 1-on-1 ports, they don't have to come in pairs
+                if peer_port.positions > 1:
+                    position_stack.append(termination.rear_port_position)
+
                 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)
+
+                    position = position_stack.pop()
+
+                    # 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 1-on-1 ports, they don't have to come in pairs
+                    position = 1
 
                 try:
                     peer_port = FrontPort.objects.get(
@@ -165,12 +173,12 @@ def get_peer_port(termination):
             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()
@@ -183,10 +191,10 @@ def get_peer_port(termination):
             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:
@@ -203,7 +211,7 @@ def get_path_endpoints(self):
         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:
diff --git a/netbox/dcim/signals.py b/netbox/dcim/signals.py
index c94ecf61eb7..7cae1a39c4f 100644
--- a/netbox/dcim/signals.py
+++ b/netbox/dcim/signals.py
@@ -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,7 +61,7 @@ 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):
             logger.debug("Updating path endpoints: {} <---> {}".format(endpoint_a, endpoint_b))
diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py
index 6db93873200..f3b8901026e 100644
--- a/netbox/dcim/tests/test_models.py
+++ b/netbox/dcim/tests/test_models.py
@@ -363,6 +363,7 @@ def setUp(self):
         )
         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,23 @@ def setUp(self):
         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.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 +419,7 @@ def test_cabletermination_deletion(self):
         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 +440,7 @@ def test_cable_front_port_cannot_connect_to_corresponding_rear_port(self):
         """
         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,6 +453,37 @@ def test_cable_cannot_terminate_to_an_existing_connection(self):
         with self.assertRaises(ValidationError):
             cable.clean()
 
+    def test_singlepos_rearport_connections(self):
+        """
+        A RearPort with one position can be connected to anything as it is just a
+        cable extender.
+        """
+        # 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()
+
+    def test_multipos_rearport_connections(self):
+        """
+        A RearPort with more than one position can only be connected to another RearPort with the same number of
+        positions.
+        """
+        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()
+
+        # Connecting a multi-position RearPort to a CircuitTermination should be ok
+        Cable(termination_a=self.rear_port1, termination_b=self.circuittermination1).full_clean()
+
     def test_cable_cannot_terminate_to_a_virtual_inteface(self):
         """
         A cable cannot terminate to a virtual interface
@@ -501,9 +546,13 @@ def setUpTestData(cls):
             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 +561,11 @@ def setUpTestData(cls):
                 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 +578,7 @@ def test_direct_connection(self):
             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
@@ -549,24 +604,27 @@ def test_direct_connection(self):
         self.assertIsNone(endpoint_a.connection_status)
         self.assertIsNone(endpoint_b.connection_status)
 
-    def test_connection_via_single_rear_port(self):
+    def test_connection_via_one_on_one_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 +650,171 @@ def test_connection_via_single_rear_port(self):
         self.assertIsNone(endpoint_a.connection_status)
         self.assertIsNone(endpoint_b.connection_status)
 
+        # Recreate cable 1 to test creating the cables in reverse order (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 5', name='Front Port 1')
+        )
+        cable1.full_clean()
+        cable1.save()
+
+        # Refresh endpoints
+        endpoint_a.refresh_from_db()
+        endpoint_b.refresh_from_db()
+
+        # Validate connections
+        self.assertEqual(endpoint_a.connected_endpoint, endpoint_b)
+        self.assertEqual(endpoint_b.connected_endpoint, endpoint_a)
+        self.assertTrue(endpoint_a.connection_status)
+        self.assertTrue(endpoint_b.connection_status)
+
+        # Delete cable 2
+        cable2.delete()
+
+        # Refresh endpoints
+        endpoint_a.refresh_from_db()
+        endpoint_b.refresh_from_db()
+
+        # Check that connections have been nullified
+        self.assertIsNone(endpoint_a.connected_endpoint)
+        self.assertIsNone(endpoint_b.connected_endpoint)
+        self.assertIsNone(endpoint_a.connection_status)
+        self.assertIsNone(endpoint_b.connection_status)
+
+    def test_connection_via_nested_one_on_one_port(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)
+
+        # Recreate cable 3 to test reverse order (Panel 5 FP first, RP second)
+        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()
+
+        # Refresh endpoints
+        endpoint_a.refresh_from_db()
+        endpoint_b.refresh_from_db()
+        endpoint_c.refresh_from_db()
+        endpoint_d.refresh_from_db()
+
+        # 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 4
+        cable4.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 +836,33 @@ def test_connections_via_patch(self):
             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 +921,51 @@ def test_connections_via_multiple_patches(self):
             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 +1025,45 @@ def test_connections_via_nested_rear_ports(self):
             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 +1113,13 @@ def test_connection_via_circuit(self):
             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 +1148,34 @@ def test_connection_via_circuit(self):
     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
diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py
index d141f93c689..68359fc05cb 100644
--- a/netbox/dcim/views.py
+++ b/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 @@ def get(self, request, model, pk):
             'obj': obj,
             'trace': path,
             'split_ends': split_ends,
+            'position_stack': position_stack,
             'total_length': total_length,
         })
 
diff --git a/netbox/templates/dcim/cable_trace.html b/netbox/templates/dcim/cable_trace.html
index 1e7210e9a2c..64c4a22d9e4 100644
--- a/netbox/templates/dcim/cable_trace.html
+++ b/netbox/templates/dcim/cable_trace.html
@@ -88,6 +88,10 @@ <h4 class="text-muted">No Cable</h4>
                     </table>
                 </div>
             </div>
+        {% elif position_stack %}
+            <div class="col-md-11 col-md-offset-1">
+                <h3 class="text-warning text-center">Multiple possible paths end at this point. No connection established!</h3>
+            </div>
         {% else %}
             <div class="col-md-11 col-md-offset-1">
                 <h3 class="text-success text-center">Trace completed!</h3>


Code review comments:
diff_hunk: @@ -439,6 +453,37 @@ def test_cable_cannot_terminate_to_an_existing_connection(self):
         with self.assertRaises(ValidationError):
             cable.clean()
 
+    def test_singlepos_rearport_connections(self):
+        """
+        A RearPort with one position can be connected to anything as it is just a
+        cable extender.
path: netbox/dcim/tests/test_models.py
text: @user1:
No such thing as a "cable extender." Be sure to explain _exactly_ what the test is doing and why it's there.

diff_hunk: @@ -2191,19 +2191,20 @@ def clean(self):
                 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 isn't connected to something silly
path: netbox/dcim/models/__init__.py
text: @user1:
What is "something silly?" Be explicit.

@author:
Sorry, should have added to TODO marker for myself there :)
Will fix!

diff_hunk: @@ -88,6 +88,10 @@ <h4 class="text-muted">No Cable</h4>
                     </table>
                 </div>
             </div>
+        {% elif position_stack %}
+            <div class="col-md-11 col-md-offset-1">
+                <h3 class="text-warning text-center">Multiple possible paths end at this point. No connection established!</h3>
+            </div>
path: netbox/templates/dcim/cable_trace.html
text: @author:
When tracing from FrontPort to RearPort there may be an endpoint, but it can/will be the endpoint from multiple traces. It is therefore not considered a connected endpoint, so explain this to the user.

@user1:
As with `split_ends`, the user should be presented options at this point. How do we convey the possible next paths?

@author:
If there is something left on `position_stack` that indicates that there are no possible next paths. There is no choice to be made. We can only notify the user that the path ends sooner than expected.

@user1:
Right, but the message returned to the user states

> Multiple possible paths end at this point

So should that be changed, or do we even need a message, since this is effectively the end of the trace?

diff_hunk: @@ -549,24 +604,27 @@ def test_direct_connection(self):
         self.assertIsNone(endpoint_a.connection_status)
         self.assertIsNone(endpoint_b.connection_status)
 
-    def test_connection_via_single_rear_port(self):
+    def test_connection_via_one_on_one_port(self):
path: netbox/dcim/tests/test_models.py
text: @user1:
There is no such thing as a "one-on-one" port. The test method name should describe exactly what's being tested to avoid confusion.