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

Fixes #22825: Handle CircuitTermination origins in cable path tracing
This commit is contained in:
bctiemann 2026-08-05 16:10:54 -04:00 committed by GitHub
commit 1f5a30dcd2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 60 additions and 9 deletions

View File

@ -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)

View File

@ -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]

View File

@ -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,

View File

@ -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>