Przeglądaj źródła

Closes #21879: Add model hook to update cable-dependent objects (#23209)

Add Cable.update_dependent_objects() to synchronize channel cable
attributes and rebuild paths after writes that bypass save().

Document the optional model hook and its invocation contract for
integrations. Run cable updates atomically and prevent template
invocation.

Add regression coverage for path preservation, repeated calls,
circuit-origin paths, and channel moves between cabled interfaces.
Arthur Hanson 15 godzin temu
rodzic
commit
9a66642a91

+ 27 - 0
docs/plugins/development/models.md

@@ -183,6 +183,33 @@ register_model_feature('foo', supports_foo)
 !!! tip
     Consider performing feature registration inside your PluginConfig's `ready()` method.
 
+## Dependent Objects
+
+Some models maintain dependent objects from their `save()` method: saving a cable, for example, traces and records its cable paths. Callers which write objects directly to the database bypass `save()` — replaying serialized changes, restoring deleted objects, or importing data — and those dependent objects are never created.
+
+A model can implement `update_dependent_objects()` to expose that work to such a caller:
+
+```python
+# models.py
+class MyModel(NetBoxModel):
+
+    def update_dependent_objects(self):
+        # Recreate any objects derived from this one
+        ...
+    update_dependent_objects.alters_data = True
+```
+
+The method is optional; callers should check for its presence before calling it. NetBox never calls it during a normal save.
+
+Two constraints apply to an implementation:
+
+* It must derive its work entirely from the database. The in-memory state a normal `save()` relies on (which fields changed, for instance) is not available to a caller replaying serialized data.
+* It must be idempotent and safe to call when nothing needs to change, as a caller will generally invoke it for every object it has written.
+
+Exceptions raised by an implementation propagate to the caller unchanged: `Cable.update_dependent_objects()` raises `UnsupportedCablePath` where `Cable.save()` converts it to `AbortRequest`. The hook is not tied to a request, so it is for the caller to decide how a failure is handled.
+
+The caller is responsible for calling the method only once every related object is in place: `Cable.update_dependent_objects()` retraces the cable's paths, which requires its `CableTermination` objects to exist.
+
 ## Choice Sets
 
 For model fields which support the selection of one or more values from a predefined list of choices, NetBox provides the `ChoiceSet` utility class. This can be used in place of a regular choices tuple to provide enhanced functionality, namely dynamic configuration and colorization. (See [Django's documentation](https://docs.djangoproject.com/en/stable/ref/models/fields/#choices) on the `choices` parameter for supported model fields.)

+ 17 - 3
netbox/dcim/models/cables.py

@@ -9,7 +9,7 @@ from django.contrib.postgres.fields import ArrayField
 from django.contrib.postgres.indexes import GinIndex
 from django.core.exceptions import ValidationError
 from django.core.validators import MaxValueValidator, MinValueValidator
-from django.db import models, router
+from django.db import models, router, transaction
 from django.dispatch import Signal
 from django.urls import reverse
 from django.utils.translation import gettext_lazy as _
@@ -19,7 +19,7 @@ from dcim.choices import *
 from dcim.constants import *
 from dcim.exceptions import UnsupportedCablePath
 from dcim.fields import PathField
-from dcim.utils import decompile_path_node, object_to_path_node
+from dcim.utils import decompile_path_node, object_to_path_node, rebuild_cable_paths
 from netbox.choices import ColorChoices
 from netbox.models import ChangeLoggedModel, PrimaryModel
 from utilities.conversion import to_meters
@@ -30,7 +30,7 @@ from utilities.querysets import RestrictedQuerySet, chunked_update
 from utilities.serialization import deserialize_object, serialize_object
 from wireless.models import WirelessLink
 
-from .device_components import FrontPort, PathEndpoint, PortMapping, RearPort
+from .device_components import FrontPort, Interface, PathEndpoint, PortMapping, RearPort
 
 __all__ = (
     'Cable',
@@ -508,6 +508,20 @@ class Cable(PrimaryModel):
 
         return instance
 
+    def update_dependent_objects(self):
+        """
+        Recreate the CablePaths traversing this Cable from its current terminations.
+        """
+        with transaction.atomic(using=router.db_for_write(CablePath)):
+
+            # Restore channel cable attributes omitted by bulk-update change logging.
+            for ct in CableTermination.objects.filter(cable=self).prefetch_related('termination'):
+                if isinstance(ct.termination, Interface) and ct.termination.channels:
+                    ct.termination.propagate_channel_cables()
+
+            rebuild_cable_paths(self)
+    update_dependent_objects.alters_data = True
+
     def get_terminations(self):
         """
         Return two dictionaries mapping A & B side terminating objects to their corresponding CableTerminations

+ 255 - 1
netbox/dcim/tests/test_cablepaths.py

@@ -1,5 +1,5 @@
 from circuits.models import *
-from dcim.choices import LinkStatusChoices
+from dcim.choices import CableEndChoices, LinkStatusChoices
 from dcim.models import *
 from dcim.svg import CableTraceSVG
 from dcim.tests.utils import BaseCablePathTestCase
@@ -16,6 +16,27 @@ class LegacyCablePathTestCase(BaseCablePathTestCase):
         3XX: Test responses to changes in existing objects
         4XX: Test to exclude specific cable topologies
     """
+    def _create_cable_raw(self, termination_a, termination_b, status=LinkStatusChoices.STATUS_CONNECTED):
+        """
+        Write a Cable and its terminations directly to the database, bypassing Cable.save(). Unprofiled
+        cables only: the connector & positions a profile assigns are not replicated here.
+        """
+        cable = Cable(status=status)
+        cable.save_base(raw=True)
+
+        for termination, cable_end in (
+            (termination_a, CableEndChoices.SIDE_A),
+            (termination_b, CableEndChoices.SIDE_B),
+        ):
+            ct = CableTermination(cable=cable, cable_end=cable_end, termination=termination)
+            ct.cache_related_objects()
+            ct.save_base(raw=True)
+            termination.cable = cable
+            termination.cable_end = cable_end
+            termination.save()
+
+        return cable
+
     def test_101_interface_to_interface(self):
         """
         [IF1] --C1-- [IF2]
@@ -2892,6 +2913,239 @@ class LegacyCablePathTestCase(BaseCablePathTestCase):
         interface3.refresh_from_db()
         self.assertPathIsNotSet(interface3)
 
+    def test_304_retrace_cable_created_without_save(self):
+        interface1 = Interface.objects.create(device=self.device, name='Interface 1')
+        interface2 = Interface.objects.create(device=self.device, name='Interface 2')
+
+        cable = self._create_cable_raw(interface1, interface2)
+        self.assertEqual(CablePath.objects.count(), 0)
+
+        cable.update_dependent_objects()
+
+        self.assertPathExists((interface1, cable, interface2), is_complete=True, is_active=True)
+        self.assertPathExists((interface2, cable, interface1), is_complete=True, is_active=True)
+        self.assertEqual(CablePath.objects.count(), 2)
+
+    def test_305_retrace_cable_extends_incomplete_path(self):
+        """
+        [IF1] --C1-- [FP1] [RP1] --C2-- [IF2], with C2 written raw. Retracing from a termination which is not
+        itself a path endpoint must extend the existing incomplete path.
+        """
+        interface1 = Interface.objects.create(device=self.device, name='Interface 1')
+        interface2 = Interface.objects.create(device=self.device, name='Interface 2')
+        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
+        )
+
+        cable1 = Cable(a_terminations=[interface1], b_terminations=[frontport1])
+        cable1.save()
+        self.assertPathExists((interface1, cable1, frontport1, rearport1), is_complete=False)
+
+        cable2 = self._create_cable_raw(rearport1, interface2)
+        self.assertEqual(CablePath.objects.count(), 1)
+
+        cable2.update_dependent_objects()
+
+        self.assertPathExists(
+            (interface1, cable1, frontport1, rearport1, cable2, interface2),
+            is_complete=True,
+            is_active=True
+        )
+        self.assertPathExists(
+            (interface2, cable2, rearport1, frontport1, cable1, interface1),
+            is_complete=True,
+            is_active=True
+        )
+        self.assertEqual(CablePath.objects.count(), 2)
+
+    def test_306_retrace_cable_status_from_database(self):
+        """
+        A raw write leaves no in-memory record of the Cable's status, so path activity must come from the
+        stored value.
+        """
+        interface1 = Interface.objects.create(device=self.device, name='Interface 1')
+        interface2 = Interface.objects.create(device=self.device, name='Interface 2')
+
+        cable = self._create_cable_raw(interface1, interface2, status=LinkStatusChoices.STATUS_PLANNED)
+        cable.update_dependent_objects()
+
+        self.assertPathExists((interface1, cable, interface2), is_complete=True, is_active=False)
+        self.assertPathExists((interface2, cable, interface1), is_complete=True, is_active=False)
+        self.assertEqual(CablePath.objects.count(), 2)
+
+    def test_307_retrace_cable_preserves_path_via_pass_through(self):
+        """
+        [IF1] --C1-- [FP1] [RP1] --C2-- [IF2]. Retracing C1, whose B side is not a path endpoint, must
+        preserve the reverse path originating at IF2.
+        """
+        interface1 = Interface.objects.create(device=self.device, name='Interface 1')
+        interface2 = Interface.objects.create(device=self.device, name='Interface 2')
+        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
+        )
+        cable1 = Cable(a_terminations=[interface1], b_terminations=[frontport1])
+        cable1.save()
+        cable2 = Cable(a_terminations=[rearport1], b_terminations=[interface2])
+        cable2.save()
+        self.assertEqual(CablePath.objects.count(), 2)
+
+        Cable.objects.get(pk=cable1.pk).update_dependent_objects()
+
+        self.assertPathExists(
+            (interface1, cable1, frontport1, rearport1, cable2, interface2),
+            is_complete=True,
+            is_active=True
+        )
+        self.assertPathExists(
+            (interface2, cable2, rearport1, frontport1, cable1, interface1),
+            is_complete=True,
+            is_active=True
+        )
+        self.assertEqual(CablePath.objects.count(), 2)
+
+    def test_308_retrace_midspan_cable_preserves_paths(self):
+        """
+        [IF1] --C1-- [FP1] [RP1] --C2-- [RP2] [FP2] --C3-- [IF2]. Retracing C2, which originates nothing
+        itself (both sides are rear ports), must preserve both paths.
+        """
+        interface1 = Interface.objects.create(device=self.device, name='Interface 1')
+        interface2 = Interface.objects.create(device=self.device, name='Interface 2')
+        ports = {}
+        for i in (1, 2):
+            ports[f'rear{i}'] = RearPort.objects.create(device=self.device, name=f'Rear Port {i}')
+            ports[f'front{i}'] = FrontPort.objects.create(device=self.device, name=f'Front Port {i}')
+            PortMapping.objects.create(
+                device=self.device,
+                front_port=ports[f'front{i}'],
+                front_port_position=1,
+                rear_port=ports[f'rear{i}'],
+                rear_port_position=1
+            )
+        cable1 = Cable(a_terminations=[interface1], b_terminations=[ports['front1']])
+        cable1.save()
+        cable2 = Cable(a_terminations=[ports['rear1']], b_terminations=[ports['rear2']])
+        cable2.save()
+        cable3 = Cable(a_terminations=[ports['front2']], b_terminations=[interface2])
+        cable3.save()
+        self.assertEqual(CablePath.objects.count(), 2)
+
+        # Twice: with no path endpoint of its own, every path this Cable carries is restored from the
+        # origins of the paths it replaces, so a repeat call must neither duplicate nor drop them
+        for _ in range(2):
+            Cable.objects.get(pk=cable2.pk).update_dependent_objects()
+
+        self.assertPathExists(
+            (
+                interface1, cable1, ports['front1'], ports['rear1'], cable2, ports['rear2'], ports['front2'],
+                cable3, interface2
+            ),
+            is_complete=True,
+            is_active=True
+        )
+        self.assertPathExists(
+            (
+                interface2, cable3, ports['front2'], ports['rear2'], cable2, ports['rear1'], ports['front1'],
+                cable1, interface1
+            ),
+            is_complete=True,
+            is_active=True
+        )
+        self.assertEqual(CablePath.objects.count(), 2)
+
+    def test_309_retrace_cable_preserves_path_via_circuit(self):
+        """
+        [IF1] --C1-- [CT1] [CT2] --C2-- [IF2]. Retracing C1, whose B side is a circuit termination, must
+        preserve the reverse path originating at IF2.
+        """
+        interface1 = Interface.objects.create(device=self.device, name='Interface 1')
+        interface2 = Interface.objects.create(device=self.device, name='Interface 2')
+        circuittermination1 = CircuitTermination.objects.create(
+            circuit=self.circuit, termination=self.site, term_side='A'
+        )
+        circuittermination2 = CircuitTermination.objects.create(
+            circuit=self.circuit, termination=self.site, term_side='Z'
+        )
+        cable1 = Cable(a_terminations=[interface1], b_terminations=[circuittermination1])
+        cable1.save()
+        cable2 = Cable(a_terminations=[circuittermination2], b_terminations=[interface2])
+        cable2.save()
+        self.assertEqual(CablePath.objects.count(), 2)
+
+        Cable.objects.get(pk=cable1.pk).update_dependent_objects()
+
+        self.assertPathExists(
+            (interface1, cable1, circuittermination1, circuittermination2, cable2, interface2),
+            is_complete=True,
+            is_active=True
+        )
+        self.assertPathExists(
+            (interface2, cable2, circuittermination2, circuittermination1, cable1, interface1),
+            is_complete=True,
+            is_active=True
+        )
+        self.assertEqual(CablePath.objects.count(), 2)
+
+    def test_310_retrace_cable_is_idempotent(self):
+        interface1 = Interface.objects.create(device=self.device, name='Interface 1')
+        interface2 = Interface.objects.create(device=self.device, name='Interface 2')
+
+        cable = Cable(a_terminations=[interface1], b_terminations=[interface2])
+        cable.save()
+        self.assertEqual(CablePath.objects.count(), 2)
+
+        cable.update_dependent_objects()
+
+        path1 = self.assertPathExists((interface1, cable, interface2), is_complete=True, is_active=True)
+        path2 = self.assertPathExists((interface2, cable, interface1), is_complete=True, is_active=True)
+        self.assertEqual(CablePath.objects.count(), 2)
+        interface1.refresh_from_db()
+        interface2.refresh_from_db()
+        self.assertPathIsSet(interface1, path1)
+        self.assertPathIsSet(interface2, path2)
+
+    def test_311_retrace_cable_preserves_circuittermination_origin(self):
+        """
+        [CT1] --C1-- [RP1] [FP1]
+
+        A CircuitTermination origin is not a PathEndpoint, so the retrace cannot reproduce its path by
+        tracing the Cable's terminations; it must be restored from the recorded origin instead.
+        """
+        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()
+
+        circuittermination1.refresh_from_db()
+        CablePath.from_origin([circuittermination1]).save()
+        self.assertEqual(CablePath.objects.count(), 1)
+
+        for _ in range(2):
+            Cable.objects.get(pk=cable1.pk).update_dependent_objects()
+
+            self.assertPathExists((circuittermination1, cable1, rearport1, frontport1), is_complete=False)
+            self.assertEqual(CablePath.objects.count(), 1)
+
     def test_401_exclude_midspan_devices(self):
         """
         [IF1] --C1-- [FP1][Test Device][RP1] --C2-- [RP2][Test Device][FP2] --C3-- [IF2]

+ 143 - 0
netbox/dcim/tests/test_channelization.py

@@ -19,9 +19,12 @@ from dcim.models import (
     Device,
     DeviceRole,
     DeviceType,
+    FrontPort,
     Interface,
     InterfaceTemplate,
     Manufacturer,
+    PortMapping,
+    RearPort,
     Site,
 )
 from dcim.svg import CableTraceSVG
@@ -468,6 +471,146 @@ class ChannelizedCablePathTestCase(BaseCablePathTestCase):
         self.assertIsNone(channel.cable_positions)
         self.assertPathIsNotSet(channel)
 
+    def test_114_update_dependent_objects_restores_channel_paths(self):
+        """
+        Cable.update_dependent_objects() must remirror the parent's cable attributes onto its channel
+        subinterfaces before retracing. Those attributes are written by a bulk update and so are never
+        change-logged: a caller replaying serialized changes leaves them empty, and the retrace would
+        otherwise expand the channelized origin to nothing and create no paths at all.
+        """
+        parent, channels = self._create_channelized_interface('et0', 4)
+        far = [
+            Interface.objects.create(device=self.device, name=f'xe{i}', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS)
+            for i in range(4)
+        ]
+        cable = Cable(profile=CableProfileChoices.BREAKOUT_1C4P_4C1P, a_terminations=[parent], b_terminations=far)
+        cable.clean()
+        cable.save()
+        self.assertEqual(CablePath.objects.count(), 8)
+
+        # Reduce the cable to the state a replayed create leaves behind: no paths, and no mirrored cable
+        # attributes on the channel subinterfaces
+        for cablepath in CablePath.objects.all():
+            cablepath.delete()
+        Interface.objects.filter(channel_id__isnull=False).update(
+            cable=None, cable_end='', cable_connector=None, cable_positions=None
+        )
+
+        Cable.objects.get(pk=cable.pk).update_dependent_objects()
+
+        self.assertEqual(CablePath.objects.count(), 8)
+        for i, (channel, far_iface) in enumerate(zip(channels, far), start=1):
+            channel.refresh_from_db()
+            far_iface.refresh_from_db()
+            self.assertEqual(channel.cable_id, cable.pk)
+            self.assertEqual(channel.cable_positions, [i])
+            forward = self.assertPathExists((channel, cable, far_iface), is_complete=True, is_active=True)
+            reverse = self.assertPathExists((far_iface, cable, channel), is_complete=True, is_active=True)
+            self.assertPathIsSet(channel, forward)
+            self.assertPathIsSet(far_iface, reverse)
+
+    def test_115_channelizing_cabled_interface_preserves_far_side_path(self):
+        """
+        [IF1] --C1-- [FP1] [RP1] --C2-- [IF2]. Channelizing IF1 retraces C1's paths; the path originating at
+        IF2 traverses C1 without terminating to it, and must survive the retrace.
+        """
+        interface1 = Interface.objects.create(
+            device=self.device, name='et0', type=InterfaceTypeChoices.TYPE_40GE_QSFP_PLUS
+        )
+        interface2 = Interface.objects.create(
+            device=self.device, name='xe0', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS
+        )
+        rearport = RearPort.objects.create(device=self.device, name='rear')
+        frontport = FrontPort.objects.create(device=self.device, name='front')
+        PortMapping.objects.create(
+            device=self.device,
+            front_port=frontport,
+            front_port_position=1,
+            rear_port=rearport,
+            rear_port_position=1
+        )
+        cable1 = Cable(a_terminations=[interface1], b_terminations=[frontport])
+        cable1.save()
+        cable2 = Cable(a_terminations=[rearport], b_terminations=[interface2])
+        cable2.save()
+        self.assertEqual(CablePath.objects.count(), 2)
+
+        # Channelize the near-end interface. It originates no path of its own from here on, but the far end's
+        # path to it is unaffected. Must be refetched: the in-memory instance predates the cable.
+        interface1.refresh_from_db()
+        interface1.channels = 4
+        interface1.save()
+
+        self.assertPathExists(
+            (interface2, cable2, rearport, frontport, cable1, interface1),
+            is_complete=True,
+            is_active=True
+        )
+        self.assertEqual(CablePath.objects.count(), 1)
+
+    def _move_channel_between_cabled_parents(self, old_name, new_name):
+        """
+        Cable two channelized parents, then move a channel subinterface from the first to the second. The
+        parents are retraced in name order, so the caller's naming decides which is processed first.
+        """
+        old_parent, old_channels = self._create_channelized_interface(old_name, 4)
+        new_parent, new_channels = self._create_channelized_interface(new_name, 4)
+        old_far = [
+            Interface.objects.create(device=self.device, name=f'xe{i}', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS)
+            for i in range(4)
+        ]
+        new_far = [
+            Interface.objects.create(device=self.device, name=f'ye{i}', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS)
+            for i in range(4)
+        ]
+        old_cable = Cable(
+            profile=CableProfileChoices.BREAKOUT_1C4P_4C1P, a_terminations=[old_parent], b_terminations=old_far
+        )
+        old_cable.clean()
+        old_cable.save()
+        new_cable = Cable(
+            profile=CableProfileChoices.BREAKOUT_1C4P_4C1P, a_terminations=[new_parent], b_terminations=new_far
+        )
+        new_cable.clean()
+        new_cable.save()
+
+        # Free position 4 on the new parent, then move the old parent's fourth channel onto it. Both the
+        # channel and its new parent must be refetched: the in-memory instances predate the cables.
+        new_channels[3].delete()
+        channel = Interface.objects.get(pk=old_channels[3].pk)
+        channel.parent = Interface.objects.get(pk=new_parent.pk)
+        channel.save()
+
+        return channel, old_cable, new_cable, old_far[3], new_far[3]
+
+    def _assert_single_origin_path(self, channel, cable, far):
+        """Assert the channel originates exactly one path, traced through the given cable."""
+        originating = [
+            cp for cp in CablePath.objects.filter(_nodes__contains=channel) if channel in cp.origins
+        ]
+        self.assertEqual(len(originating), 1, msg=f'{len(originating)} paths originate at {channel}; expected 1')
+        self.assertPathExists((channel, cable, far), is_complete=True, is_active=True)
+
+    def test_116_move_channel_between_cabled_parents_old_first(self):
+        """
+        Moving a channel subinterface between two cabled channelized parents, the old parent retraced first.
+        The channel's stale mirrored cable must not resurrect a path through the old cable.
+        """
+        channel, old_cable, new_cable, old_far, new_far = self._move_channel_between_cabled_parents('et0', 'et1')
+
+        self._assert_single_origin_path(channel, new_cable, new_far)
+        self.assertPathDoesNotExist((channel, old_cable, old_far))
+
+    def test_117_move_channel_between_cabled_parents_new_first(self):
+        """
+        The same move with the new parent retraced first: restoring the old path must not duplicate the one
+        already traced through the new cable.
+        """
+        channel, old_cable, new_cable, old_far, new_far = self._move_channel_between_cabled_parents('et1', 'et0')
+
+        self._assert_single_origin_path(channel, new_cable, new_far)
+        self.assertPathDoesNotExist((channel, old_cable, old_far))
+
 
 class ChannelizedInterfaceTestCase(TestCase):
     """

+ 42 - 12
netbox/dcim/utils.py

@@ -177,32 +177,62 @@ def rebuild_paths(terminations):
 
 def rebuild_cable_paths(cable):
     """
-    Delete and rebuild every CablePath traversing the given Cable, tracing freshly from the Cable's current
-    terminations in both directions. Used when the channelization of a terminated interface changes (e.g. a channel
-    subinterface is added, moved, or removed) without the Cable itself being modified.
+    Delete and rebuild every CablePath affected by the given Cable, tracing freshly from the Cable's current
+    terminations and from the origins of the affected paths. Used when a Cable's connectivity must be reconciled
+    without its own save() having traced it: the channelization of a terminated interface has changed, or the Cable
+    was written by a process which bypasses save().
     """
     from dcim.choices import CableEndChoices
     from dcim.models import CablePath, CableTermination, PathEndpoint
 
     with transaction.atomic(using=router.db_for_write(CablePath)):
-        # Delete existing paths individually so each clears its `_path` back-reference on the originating endpoints.
-        for cp in CablePath.objects.filter(_nodes__contains=cable):
-            cp.delete()
-
         a_terminations, b_terminations = [], []
-        for ct in CableTermination.objects.filter(cable=cable):
+        for ct in CableTermination.objects.filter(cable=cable).prefetch_related('termination'):
             if ct.cable_end == CableEndChoices.SIDE_A:
                 a_terminations.append(ct.termination)
             else:
                 b_terminations.append(ct.termination)
 
+        # Every path traversing the Cable, plus those traversing a termination which is not itself a path endpoint:
+        # the latter may not reach the Cable yet (e.g. an incomplete path through a pass-through port which this
+        # Cable completes).
+        affected = {cp.pk: cp for cp in CablePath.objects.filter(_nodes__contains=cable)}
+        for termination in (*a_terminations, *b_terminations):
+            if not isinstance(termination, PathEndpoint):
+                affected.update({cp.pk: cp for cp in CablePath.objects.filter(_nodes__contains=termination)})
+
+        # Record each affected path's originating node(s) before deleting it. These are kept as compiled path
+        # nodes; resolving them to objects is deferred to the paths which actually need restoring.
+        origin_keys = {tuple(cp.path[0]) for cp in affected.values()}
+
+        # Delete existing paths individually so each clears its `_path` back-reference on the originating endpoints.
+        for cp in affected.values():
+            cp.delete()
+
+        # Trace from the Cable's own terminations first, so that a channelized origin is expanded into its channel
+        # subinterfaces exactly once
         for nodes in (a_terminations, b_terminations):
+            if nodes and isinstance(nodes[0], PathEndpoint):
+                create_cablepaths(nodes)
+        retraced = {tuple(cp.path[0]) for cp in CablePath.objects.filter(_nodes__contains=cable)}
+
+        # Restore the affected paths which merely passed through the Cable: those originate elsewhere, so the
+        # tracing above cannot reproduce them.
+        for key in origin_keys - retraced:
+            nodes = [obj for node in key if (obj := path_node_to_object(node))]
             if not nodes:
                 continue
-            if isinstance(nodes[0], PathEndpoint):
-                create_cablepaths(nodes)
-            else:
-                rebuild_paths(nodes)
+
+            # A path endpoint terminating this Cable belongs to the tracing above: that it produced no path
+            # means the origin no longer has one (e.g. a channel subinterface moved to another parent).
+            if any(isinstance(obj, PathEndpoint) and obj.cable_id == cable.pk for obj in nodes):
+                continue
+
+            # Nor restore an origin whose path has already been traced through another Cable
+            if key in {tuple(cp.path[0]) for cp in CablePath.objects.filter(_nodes__contains=nodes[0])}:
+                continue
+
+            create_cablepaths(nodes)
 
 
 def update_interface_parents(device, interface_templates, module=None):