From a158ed3794c61d32e448cb23b0c0c854e087cc2c Mon Sep 17 00:00:00 2001 From: Jason Novinger Date: Mon, 3 Aug 2026 16:10:32 -0500 Subject: [PATCH] Fixes #22825: Handle CircuitTermination origins in cable path tracing CablePath.save() and delete() wrote the _path back-reference onto the path's origin object, and PathTraceView built the trace SVG URL from the origin's REST API action. Both assume the origin is a PathEndpoint, but a CircuitTermination is a valid cable-path origin (per from_origin) without the _path field or a -trace API action, so those paths raised FieldDoesNotExist and NoReverseMatch respectively. Guard the _path writes and the SVG URL on PathEndpoint membership, and skip the SVG block in the template when no URL is available. --- netbox/dcim/models/cables.py | 16 +++++++---- netbox/dcim/tests/test_cablepaths.py | 39 ++++++++++++++++++++++++++ netbox/dcim/views.py | 12 ++++++-- netbox/templates/dcim/cable_trace.html | 2 ++ 4 files changed, 60 insertions(+), 9 deletions(-) diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index 868ab0822..73b023913 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -780,18 +780,22 @@ class CablePath(models.Model): super().save(*args, **kwargs) - # Record a direct reference to this CablePath on its originating object(s) + # Record a direct reference to this CablePath on its originating object(s). Only PathEndpoint + # subclasses carry the denormalized `_path` back-reference; other valid origins (e.g. + # CircuitTermination) do not, so skip the update for them. origin_model = self.origin_type.model_class() - origin_ids = [decompile_path_node(node)[1] for node in self.path[0]] - origin_model.objects.filter(pk__in=origin_ids).update(_path=self.pk) + if issubclass(origin_model, PathEndpoint): + origin_ids = [decompile_path_node(node)[1] for node in self.path[0]] + origin_model.objects.filter(pk__in=origin_ids).update(_path=self.pk) def delete(self, *args, **kwargs): # Mirror save() - clear _path on origins to prevent stale references - # in table views that render _path.destinations + # in table views that render _path.destinations. Only PathEndpoint subclasses carry `_path`. if self.path: origin_model = self.origin_type.model_class() - origin_ids = [decompile_path_node(node)[1] for node in self.path[0]] - origin_model.objects.filter(pk__in=origin_ids, _path=self.pk).update(_path=None) + if issubclass(origin_model, PathEndpoint): + origin_ids = [decompile_path_node(node)[1] for node in self.path[0]] + origin_model.objects.filter(pk__in=origin_ids, _path=self.pk).update(_path=None) super().delete(*args, **kwargs) diff --git a/netbox/dcim/tests/test_cablepaths.py b/netbox/dcim/tests/test_cablepaths.py index d60f5cc86..ec7de6020 100644 --- a/netbox/dcim/tests/test_cablepaths.py +++ b/netbox/dcim/tests/test_cablepaths.py @@ -2680,6 +2680,45 @@ class LegacyCablePathTestCase(BaseCablePathTestCase): ) self.assertEqual(CablePath.objects.count(), 2) + def test_225_circuittermination_origin_passive_network(self): + """ + [CT1] --C1-- [RP1] [FP1] + + A CircuitTermination cabled into a passive (FrontPort/RearPort-only) device can become a + CablePath origin. Unlike PathEndpoint origins, CircuitTermination has no `_path` back-reference + field, so saving and deleting such a path must not attempt to write it (see #22825). + """ + rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1') + frontport1 = FrontPort.objects.create(device=self.device, name='Front Port 1') + PortMapping.objects.create( + device=self.device, front_port=frontport1, front_port_position=1, + rear_port=rearport1, rear_port_position=1, + ) + circuittermination1 = CircuitTermination.objects.create( + circuit=self.circuit, + termination=self.site, + term_side='A' + ) + cable1 = Cable( + a_terminations=[circuittermination1], + b_terminations=[rearport1] + ) + cable1.save() + + # Re-fetch so the in-memory instance reflects the cable set above (from_origin reads .cable). + circuittermination1.refresh_from_db() + + # A path traced from the CircuitTermination origin must save without raising FieldDoesNotExist + # on the missing `_path` field. + cablepath = CablePath.from_origin([circuittermination1]) + cablepath.save() + self.assertEqual(cablepath.origin_type.model_class(), CircuitTermination) + self.assertEqual(cablepath.origins, [circuittermination1]) + + # Deleting the path must likewise not attempt to clear a nonexistent `_path` field. + cablepath.delete() + self.assertIsNone(CablePath.objects.filter(pk=cablepath.pk).first()) + def test_301_create_path_via_existing_cable(self): """ [IF1] --C1-- [FP1] [RP1] --C2-- [RP2] [FP2] --C3-- [IF2] diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index cc888597d..987493641 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -208,9 +208,15 @@ class PathTraceView(generic.ObjectView): # Get the total length of the cable and whether the length is definitive (fully defined) total_length, is_definitive = path.get_total_length() if path else (None, False) - # Determine the path to the SVG trace image - api_viewname = f"{path.origin_type.app_label}-api:{path.origin_type.model}-trace" - svg_url = f"{reverse(api_viewname, kwargs={'pk': path.origins[0].pk})}?render=svg" + # Determine the path to the SVG trace image. The `-trace` API action (and the SVG renderer, + # which calls origin.trace()) exist only for PathEndpoint origins. Other valid origins such as + # CircuitTermination have no such action, so omit the SVG for them. + origin_model = path.origin_type.model_class() + if issubclass(origin_model, PathEndpoint): + api_viewname = f"{path.origin_type.app_label}-api:{path.origin_type.model}-trace" + svg_url = f"{reverse(api_viewname, kwargs={'pk': path.origins[0].pk})}?render=svg" + else: + svg_url = None return { 'path': path, diff --git a/netbox/templates/dcim/cable_trace.html b/netbox/templates/dcim/cable_trace.html index e07b12d81..bc7be377e 100644 --- a/netbox/templates/dcim/cable_trace.html +++ b/netbox/templates/dcim/cable_trace.html @@ -17,6 +17,7 @@ {# Cable trace SVG & options #}
{% if path %} + {% if svg_url %}
@@ -25,6 +26,7 @@
+ {% endif %}
{% if path.is_split and path.get_asymmetric_nodes %}

{% trans "Asymmetric Path" %}!