Repository: netbox-community/netbox

Issue:
Rendering of cable trace broken after upgrading from 3.7.6 to 3.7.7
### Deployment Type

Self-hosted

### NetBox Version

3.7.7

### Python Version

3.9

### Steps to Reproduce

Load the cable trace page, eg https://netbox.example.com/dcim/interfaces/2283/trace/

### Expected Behavior

An SVG of the cable trace should be visible on the top left of the page, and the download link should provide the .svg file.

### Observed Behavior

No render is visible, and the download link gives an error page:
```
<class 'AttributeError'>

'Circuit' object has no attribute 'name'

Python version: 3.9.18
NetBox version: 3.7.7
Plugins: 
  django3_saml2_nbplugin: 2.0
  netbox_bgp: 0.12.1
```


Discussion:
Thanks for reporting a problem you encountered.

I was unable to reproduce it using the provided steps. I did start with an empty, fresh 3.7.6 install, to which I added two devices, each with 24 interfaces, and connected those devices to each other and confirmed I could do a cable trace successfully. Then I upgraded that system to 3.7.7 and restarted the NetBox services. After the upgrade I was still able to perform the same trace. For good measure I tried tracing in the reverse direction, which also succeeded.

Have I omitted a step that was part of your flow?
![Screenshot 2024-05-02 at 16 51 41](https://github.com/netbox-community/netbox/assets/1209268/8f674d9e-6e25-415d-a0f8-7e0e2c2eacfc)
![Screenshot 2024-05-02 at 16 54 51](https://github.com/netbox-community/netbox/assets/1209268/f3e0b83c-859d-4af4-bbe8-0f6241f89ae1)

Hi, i can confirm this Bug. We also can not trace cables on Circuits after updating from v3.7.4 to 3.7.7.
I am testing in the moment, it does work on for example Devices directly, I have seen it on a Switch(Ports), a PDUs and Power Feed.... 
but it does not  work with Circuits!
<img width="623" alt="image" src="https://github.com/netbox-community/netbox/assets/149795719/08b6b9ca-7e3c-4d61-a3f8-3c1d229bfc0f">

This was broken in PR #14750. Circuits do not have a `name` attribute (they are represented by circuit ID):

https://github.com/netbox-community/netbox/blob/39a830798ea870d04182cb5ef15540dfd4c7ca40/netbox/dcim/svg/cables.py#L226

Resolving patch:
diff --git a/netbox/dcim/svg/cables.py b/netbox/dcim/svg/cables.py
--- a/netbox/dcim/svg/cables.py
+++ b/netbox/dcim/svg/cables.py
@@ -223,7 +223,7 @@ def draw_object_terminations(self, terminations, offset_x, width):
         nodes_height = 0
         nodes = []
         # Sort them by name to make renders more readable
-        for i, term in enumerate(sorted(terminations, key=lambda x: x.name)):
+        for i, term in enumerate(sorted(terminations, key=lambda x: str(x))):
             node = Node(
                 position=(offset_x + i * width, self.cursor),
                 width=width,
@@ -266,7 +266,7 @@ def draw_far_objects(self, obj_list, terminations):
         Draw the far-end objects and its terminations and return all created nodes
         """
         # Make sure elements are sorted by name for readability
-        objects = sorted(obj_list, key=lambda x: x.name)
+        objects = sorted(obj_list, key=lambda x: str(x))
         width = self.width / len(objects)
 
         # Max-height of created terminations
@@ -361,7 +361,8 @@ def render(self):
             # Connector (a Cable or WirelessLink)
             if links:
 
-                parent_object_nodes, far_terminations = self.draw_far_objects(set(end.parent_object for end in far_ends), far_ends)
+                obj_list = {end.parent_object for end in far_ends}
+                parent_object_nodes, far_terminations = self.draw_far_objects(obj_list, far_ends)
                 for cable in links:
                     # Fill in labels and description with all available data
                     description = [


Test patch:
diff --git a/netbox/dcim/tests/test_cablepaths.py b/netbox/dcim/tests/test_cablepaths.py
--- a/netbox/dcim/tests/test_cablepaths.py
+++ b/netbox/dcim/tests/test_cablepaths.py
@@ -394,6 +394,9 @@ def test_201_single_path_via_pass_through(self):
         )
         self.assertEqual(CablePath.objects.count(), 2)
 
+        # Test SVG generation
+        CableTraceSVG(interface1).render()
+
         # Delete cable 2
         cable2.delete()
         path1 = self.assertPathExists(
@@ -450,6 +453,9 @@ def test_202_single_path_via_pass_through_with_breakouts(self):
         )
         self.assertEqual(CablePath.objects.count(), 2)
 
+        # Test SVG generation
+        CableTraceSVG(interface1).render()
+
         # Delete cable 2
         cable2.delete()
         path1 = self.assertPathExists(
@@ -558,6 +564,9 @@ def test_203_multiple_paths_via_pass_through(self):
         )
         self.assertEqual(CablePath.objects.count(), 4)
 
+        # Test SVG generation
+        CableTraceSVG(interface1).render()
+
         # Delete cable 3
         cable3.delete()
 
@@ -673,6 +682,9 @@ def test_204_multiple_paths_via_pass_through_with_breakouts(self):
         )
         self.assertEqual(CablePath.objects.count(), 4)
 
+        # Test SVG generation
+        CableTraceSVG(interface1).render()
+
         # Delete cable 3
         cable3.delete()
 
@@ -804,6 +816,9 @@ def test_205_multiple_paths_via_nested_pass_throughs(self):
         )
         self.assertEqual(CablePath.objects.count(), 4)
 
+        # Test SVG generation
+        CableTraceSVG(interface1).render()
+
         # Delete cable 3
         cable3.delete()
 
@@ -931,6 +946,9 @@ def test_206_multiple_paths_via_multiple_pass_throughs(self):
         )
         self.assertEqual(CablePath.objects.count(), 4)
 
+        # Test SVG generation
+        CableTraceSVG(interface1).render()
+
         # Delete cable 5
         cable5.delete()
 
@@ -1034,6 +1052,9 @@ def test_207_multiple_paths_via_patched_pass_throughs(self):
         )
         self.assertEqual(CablePath.objects.count(), 4)
 
+        # Test SVG generation
+        CableTraceSVG(interface1).render()
+
         # Delete cable 3
         cable3.delete()
 
@@ -1093,6 +1114,9 @@ def test_208_unidirectional_split_paths(self):
         )
         self.assertEqual(CablePath.objects.count(), 3)
 
+        # Test SVG generation
+        CableTraceSVG(interface1).render()
+
         # Delete cable 1
         cable1.delete()
 
@@ -1135,6 +1159,9 @@ def test_209_rearport_without_frontport(self):
         )
         self.assertEqual(CablePath.objects.count(), 1)
 
+        # Test SVG generation
+        CableTraceSVG(interface1).render()
+
     def test_210_interface_to_circuittermination(self):
         """
         [IF1] --C1-- [CT1]
@@ -1156,6 +1183,9 @@ def test_210_interface_to_circuittermination(self):
         )
         self.assertEqual(CablePath.objects.count(), 1)
 
+        # Test SVG generation
+        CableTraceSVG(interface1).render()
+
         # Delete cable 1
         cable1.delete()
         self.assertEqual(CablePath.objects.count(), 0)
@@ -1212,6 +1242,9 @@ def test_211_interface_to_interface_via_circuit(self):
         )
         self.assertEqual(CablePath.objects.count(), 2)
 
+        # Test SVG generation
+        CableTraceSVG(interface1).render()
+
         # Delete cable 2
         cable2.delete()
         path1 = self.assertPathExists(
@@ -1277,6 +1310,9 @@ def test_212_interface_to_interface_via_circuit_with_breakouts(self):
         )
         self.assertEqual(CablePath.objects.count(), 2)
 
+        # Test SVG generation
+        CableTraceSVG(interface1).render()
+
         # Delete cable 2
         cable2.delete()
         path1 = self.assertPathExists(
@@ -1314,6 +1350,9 @@ def test_213_interface_to_site_via_circuit(self):
         )
         self.assertEqual(CablePath.objects.count(), 1)
 
+        # Test SVG generation
+        CableTraceSVG(interface1).render()
+
         # Delete cable 1
         cable1.delete()
         self.assertEqual(CablePath.objects.count(), 0)
@@ -1342,6 +1381,9 @@ def test_214_interface_to_providernetwork_via_circuit(self):
         self.assertEqual(CablePath.objects.count(), 1)
         self.assertTrue(CablePath.objects.first().is_complete)
 
+        # Test SVG generation
+        CableTraceSVG(interface1).render()
+
         # Delete cable 1
         cable1.delete()
         self.assertEqual(CablePath.objects.count(), 0)
@@ -1439,6 +1481,9 @@ def test_215_multiple_paths_via_circuit(self):
         )
         self.assertEqual(CablePath.objects.count(), 4)
 
+        # Test SVG generation
+        CableTraceSVG(interface1).render()
+
         # Delete cables 3-4
         cable3.delete()
         cable4.delete()
@@ -1495,6 +1540,9 @@ def test_216_interface_to_interface_via_multiple_circuits(self):
         )
         self.assertEqual(CablePath.objects.count(), 2)
 
+        # Test SVG generation
+        CableTraceSVG(interface1).render()
+
         # Delete cable 2
         cable2.delete()
         path1 = self.assertPathExists(
@@ -1578,6 +1626,9 @@ def test_217_interface_to_interface_via_rear_ports(self):
         )
         self.assertEqual(CablePath.objects.count(), 2)
 
+        # Test SVG generation
+        CableTraceSVG(interface1).render()
+
         # Delete cable 2
         cable2.delete()
 
@@ -1697,6 +1748,9 @@ def test_218_interfaces_to_interfaces_via_multiposition_rear_ports(self):
         )
         self.assertEqual(CablePath.objects.count(), 4)
 
+        # Test SVG generation
+        CableTraceSVG(interface1).render()
+
         # Delete cable 3
         cable3.delete()
 
@@ -1784,6 +1838,9 @@ def test_219_interface_to_interface_duplex_via_multiple_rearports(self):
         )
         self.assertEqual(CablePath.objects.count(), 2)
 
+        # Test SVG generation
+        CableTraceSVG(interface1).render()
+
     def test_220_interface_to_interface_duplex_via_multiple_front_and_rear_ports(self):
         """
         [IF1] --C1-- [FP1] [RP1] --C2-- [RP2] [FP2] --C3-- [IF2]
@@ -1877,6 +1934,9 @@ def test_220_interface_to_interface_duplex_via_multiple_front_and_rear_ports(sel
         )
         self.assertEqual(CablePath.objects.count(), 3)
 
+        # Test SVG generation
+        CableTraceSVG(interface1).render()
+
     def test_221_non_symmetric_paths(self):
         """
         [IF1] --C1-- [FP1] [RP1] --C2-- [RP2] [FP2] --C3-- -------------------------------------- [IF2]
@@ -1997,6 +2057,9 @@ def test_221_non_symmetric_paths(self):
         )
         self.assertEqual(CablePath.objects.count(), 3)
 
+        # Test SVG generation
+        CableTraceSVG(interface1).render()
+
     def test_301_create_path_via_existing_cable(self):
         """
         [IF1] --C1-- [FP1] [RP1] --C2-- [RP2] [FP2] --C3-- [IF2]