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

Merge pull request #22859 from netbox-community/22825-circuit-term-fielddoesnotexist

Fixes #22825: Handle CircuitTermination origins in cable path tracing
bctiemann 2 недель назад
Родитель
Сommit
1f5a30dcd2

+ 10 - 6
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)
 

+ 39 - 0
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]

+ 9 - 3
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,

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

@@ -17,6 +17,7 @@
         {# Cable trace SVG & options #}
         <div class="col col-md-5">
           {% if path %}
+            {% if svg_url %}
             <div class="text-center my-3">
               <object data="{{ svg_url }}" class="rack_elevation"></object>
               <div>
@@ -25,6 +26,7 @@
                 </a>
               </div>
             </div>
+            {% endif %}
             <div class="trace-end">
                 {% if path.is_split and path.get_asymmetric_nodes %}
                     <h3 class="text-danger">{% trans "Asymmetric Path" %}!</h3>