Преглед изворни кода

#20972: Pre-release QA (#22874)

Allow channel subinterfaces to retain a specific physical interface type
and rename conventionally named children when their parent is renamed.
Keep mirrored cable and path state consistent when channel bindings
change, avoid unnecessary path rebuilds, and apply the same rename
behavior to interface templates.
bctiemann пре 1 недеља
родитељ
комит
84d0cdad63

+ 5 - 5
docs/models/dcim/interface.md

@@ -28,14 +28,14 @@ An alternative physical label identifying the interface.
 
 ### Type
 
-The type of interface. Interfaces may be physical or virtual in nature, but only physical interfaces may be connected via cables. The generic **channel** type identifies a [channelized subinterface](#channel-id) bound to a parent interface.
+The type of interface. Interfaces may be physical or virtual in nature, but only physical interfaces may be connected via cables. The generic **channel** type identifies a [channelized subinterface](#channel-id) bound to a parent interface when its specific transceiver type is not relevant; a channel subinterface may instead keep its own specific physical type (e.g. directly declaring a channel as 10GBASE-SR) to record the actual transceiver in use.
 
 !!! note
     The interface type refers to the physical termination or port on the device. Interfaces which employ a removable optic or similar transceiver should be defined to represent the type of transceiver in use, irrespective of the physical termination to that transceiver.
 
 ### Channels
 
-For a channelized (breakout) interface, the number of physical channels into which the interface is divided. For example, a 40GE interface broken out into four 10GE channels would have `channels` set to four. Each channel is modeled as a channel-type subinterface bound to this interface via its [channel ID](#channel-id).
+For a channelized (breakout) interface, the number of physical channels into which the interface is divided. For example, a 40GE interface broken out into four 10GE channels would have `channels` set to four. Each channel is modeled as a channel subinterface bound to this interface via its [channel ID](#channel-id).
 
 A single physical cable terminates to the channelized (parent) interface, occupying one connector shared by all of its channels; NetBox traces a distinct cable path for each channel subinterface. Only one layer of channelization is supported: an interface cannot be both channelized and itself bound to a channel.
 
@@ -84,14 +84,14 @@ If selected, this component will be treated as if a cable has been connected.
 
 ### Parent Interface
 
-Virtual interfaces can be bound to a physical parent interface. This is helpful for modeling virtual interfaces which employ encapsulation on a physical interface, such as an 802.1Q VLAN-tagged subinterface. Channel-type subinterfaces are likewise bound to their [channelized](#channels) parent interface.
+Virtual interfaces can be bound to a physical parent interface. This is helpful for modeling virtual interfaces which employ encapsulation on a physical interface, such as an 802.1Q VLAN-tagged subinterface. A channel subinterface is likewise bound to its [channelized](#channels) parent interface, whether it uses the generic **channel** type or its own specific physical type.
 
 !!! note
-    An interface with one or more child interfaces assigned cannot be deleted until all its child interfaces have been deleted or reassigned.
+    An interface with one or more child interfaces assigned cannot be deleted until all its child interfaces have been deleted or reassigned. Renaming a channelized interface updates the names of any channel subinterfaces which follow the `<name>:<channel ID>` convention, to keep their names consistent with their new parent, unless the resulting name is already in use by another interface on the device or would exceed the maximum length of the name field (in either case, that subinterface's name is left unchanged).
 
 ### Channel ID
 
-For a channel-type subinterface, the numeric channel on its [channelized](#channels) parent interface to which this subinterface is bound. The channel ID must fall within the range of channels provided by the parent (e.g. one through four for a parent with four channels). A channel subinterface derives its cable connection from the parent's; it cannot be cabled directly.
+The numeric channel on a [channelized](#channels) parent interface to which this subinterface is bound, identifying it as a channel subinterface. This may be set on the generic **channel** type, or on any other physical interface type (e.g. to record the specific transceiver used on that channel) — but not on a virtual or wireless interface. The channel ID must fall within the range of channels provided by the parent (e.g. one through four for a parent with four channels). A channel subinterface derives its cable connection from the parent's; it cannot be cabled directly.
 
 !!! note "Channel IDs are one-indexed"
     Channel IDs increment starting at one, even for interfaces with a zero-based identifier. This ensures that each subinterface maps cleanly to the profile of an attached cable.

+ 3 - 1
netbox/dcim/filtersets.py

@@ -2570,7 +2570,9 @@ class InterfaceFilterSet(
     def filter_kind(self, queryset, name, value):
         value = value.strip().lower()
         return {
-            'physical': queryset.exclude(type__in=NONCONNECTABLE_IFACE_TYPES),
+            # A channel subinterface is excluded even if its type is otherwise connectable: it derives its cable
+            # from its channelized parent and cannot be cabled directly (matches Interface.is_wired).
+            'physical': queryset.exclude(type__in=NONCONNECTABLE_IFACE_TYPES).filter(channel_id__isnull=True),
             'virtual': queryset.filter(type__in=VIRTUAL_IFACE_TYPES),
             'wireless': queryset.filter(type__in=WIRELESS_IFACE_TYPES),
         }.get(value, queryset.none())

+ 5 - 1
netbox/dcim/graphql/filters.py

@@ -635,7 +635,11 @@ class InterfaceFilter(
         prefix: str
     ):
         if value == InterfaceKindEnum.KIND_PHYSICAL:
-            return queryset, ~Q(**{f"{prefix}type__in": NONCONNECTABLE_IFACE_TYPES})
+            # A channel subinterface is excluded even if its type is otherwise connectable: it derives its cable
+            # from its channelized parent and cannot be cabled directly (matches Interface.is_wired).
+            return queryset, ~Q(**{f"{prefix}type__in": NONCONNECTABLE_IFACE_TYPES}) & Q(
+                **{f"{prefix}channel_id__isnull": True}
+            )
         if value == InterfaceKindEnum.KIND_VIRTUAL:
             return queryset, Q(**{f"{prefix}type__in": VIRTUAL_IFACE_TYPES})
         if value == InterfaceKindEnum.KIND_WIRELESS:

+ 2 - 9
netbox/dcim/models/device_component_templates.py

@@ -8,7 +8,7 @@ from django.utils.translation import gettext_lazy as _
 from dcim.choices import *
 from dcim.constants import *
 from dcim.models.base import PortMappingBase
-from dcim.models.mixins import DiameterMixin, InterfaceValidationMixin, MaxFlowMixin
+from dcim.models.mixins import DiameterMixin, InterfaceChannelRenameMixin, InterfaceValidationMixin, MaxFlowMixin
 from dcim.utils import get_module_bay_positions, resolve_module_placeholder
 from netbox.models import ChangeLoggedModel
 from netbox.models.features import ChangeLoggingMixin
@@ -568,7 +568,7 @@ class CoolingOutflowTemplate(DiameterMixin, ModularComponentTemplateModel):
         }
 
 
-class InterfaceTemplate(InterfaceValidationMixin, ModularComponentTemplateModel):
+class InterfaceTemplate(InterfaceChannelRenameMixin, InterfaceValidationMixin, ModularComponentTemplateModel):
     """
     A template for a physical data interface on a new Device.
     """
@@ -663,13 +663,6 @@ class InterfaceTemplate(InterfaceValidationMixin, ModularComponentTemplateModel)
         verbose_name = _('interface template')
         verbose_name_plural = _('interface templates')
 
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-
-        # Cache the original channel count for use by InterfaceValidationMixin.clean() to detect a channel-count
-        # reduction that would orphan a bound subinterface.
-        self._original_channels = self.__dict__.get('channels')
-
     def clean(self):
         super().clean()
 

+ 22 - 7
netbox/dcim/models/device_components.py

@@ -15,6 +15,7 @@ from dcim.models.base import PortMappingBase
 from dcim.models.mixins import (
     CoolingLoopValidationMixin,
     DiameterMixin,
+    InterfaceChannelRenameMixin,
     InterfaceValidationMixin,
     MaxFlowMixin,
 )
@@ -928,6 +929,7 @@ class BaseInterface(models.Model):
 
 
 class Interface(
+    InterfaceChannelRenameMixin,
     InterfaceValidationMixin,
     ModularComponentModel,
     BaseInterface,
@@ -1129,13 +1131,12 @@ class Interface(
         )
 
     def __init__(self, *args, **kwargs):
+        # InterfaceChannelRenameMixin.__init__() (reached via super(), first in the MRO) sets _original_channels, used
+        # below by InterfaceValidationMixin.clean() and by post_save signal handlers.
         super().__init__(*args, **kwargs)
 
         # Cache channelization-related fields so post-save signal handlers can detect changes which require rebuilding
         # cable paths (channelization does not involve modifying the Cable itself, so the cable signals do not fire).
-        # _original_channels is additionally used by InterfaceValidationMixin.clean() to detect a channel-count
-        # reduction that would orphan a bound subinterface.
-        self._original_channels = self.__dict__.get('channels')
         self._original_channel_id = self.__dict__.get('channel_id')
         self._original_parent_id = self.__dict__.get('parent_id')
 
@@ -1158,6 +1159,18 @@ class Interface(
                 )
             })
 
+        # A channel subinterface's cable state is mirrored from its channelized parent (see
+        # update_channelized_cable_paths()), so it cannot also carry its own CableTermination -- checking
+        # cable_terminations rather than self.cable, since a valid channel child's self.cable is expected to
+        # already reflect the parent's mirrored cable.
+        if self.channel_id is not None and self.cable_terminations.exists():
+            raise ValidationError({
+                'channel_id': _(
+                    "A channel ID cannot be assigned to an interface with an existing cable connection. Remove "
+                    "the cable first."
+                )
+            })
+
         # Parent validation (self-reference and interface-type restrictions are enforced by InterfaceValidationMixin)
 
         # An interface's parent must belong to the same device or virtual chassis
@@ -1272,6 +1285,8 @@ class Interface(
         if self.rf_channel and not self.rf_channel_width:
             self.rf_channel_width = get_channel_attr(self.rf_channel, 'width')
 
+        # InterfaceChannelRenameMixin.save() (reached via super(), first in the MRO) detects and cascades a channelized
+        # parent rename around this call.
         super().save(*args, **kwargs)
 
     @property
@@ -1280,9 +1295,8 @@ class Interface(
 
     @property
     def is_wired(self):
-        # Excludes virtual, wireless, and channel-type interfaces (channel subinterfaces derive their cable from the
-        # channelized parent and cannot be cabled directly).
-        return self.type not in NONCONNECTABLE_IFACE_TYPES
+        # Also excludes any channel subinterface, which derives its cable from the channelized parent.
+        return self.type not in NONCONNECTABLE_IFACE_TYPES and self.channel_id is None
 
     @property
     def is_virtual(self):
@@ -1302,7 +1316,8 @@ class Interface(
 
     @property
     def is_channel(self):
-        return self.type == InterfaceTypeChoices.TYPE_CHANNEL
+        # Identified by channel_id, not type — it may keep its own specific physical type instead of "channel".
+        return self.channel_id is not None
 
     @property
     def link(self):

+ 120 - 16
netbox/dcim/models/mixins.py

@@ -4,7 +4,7 @@ from django.apps import apps
 from django.contrib.contenttypes.fields import GenericForeignKey
 from django.core.exceptions import ValidationError
 from django.core.validators import MinValueValidator
-from django.db import models
+from django.db import IntegrityError, models, transaction
 from django.utils.translation import gettext_lazy as _
 
 from dcim.choices import InterfaceTypeChoices
@@ -19,6 +19,7 @@ __all__ = (
     'CachedScopeMixin',
     'CoolingLoopValidationMixin',
     'DiameterMixin',
+    'InterfaceChannelRenameMixin',
     'InterfaceValidationMixin',
     'MaxFlowMixin',
     'RenderConfigMixin',
@@ -146,11 +147,24 @@ class InterfaceValidationMixin:
         if self.pk and self.parent_id == self.pk:
             raise ValidationError({'parent': _("An interface cannot be its own parent.")})
 
-        # Only virtual and channel interfaces may have a parent interface
-        if self.parent_id and self.type not in (InterfaceTypeChoices.TYPE_VIRTUAL, InterfaceTypeChoices.TYPE_CHANNEL):
-            raise ValidationError({
-                'parent': _("Only virtual and channel interfaces may be assigned to a parent interface.")
-            })
+        # A channel subinterface may keep its own specific physical type (e.g. 10GBASE-SR) instead of the
+        # generic "channel" type, but never a virtual or wireless type.
+        can_bind_to_channel = (
+            self.type == InterfaceTypeChoices.TYPE_CHANNEL or self.type not in NONCONNECTABLE_IFACE_TYPES
+        )
+        # During bulk-creation pattern validation (a replication base), channel_id is not yet assigned — it is
+        # supplied per-instance during expansion — so the parent/channel_id presence checks below are relaxed.
+        is_replicated_base = getattr(self, '_replicated_base', False)
+
+        # An interface may have a parent only if virtual, or bound to a channel on that parent.
+        if self.parent_id and self.type != InterfaceTypeChoices.TYPE_VIRTUAL:
+            if self.channel_id is None and not (is_replicated_base and can_bind_to_channel):
+                raise ValidationError({
+                    'parent': _(
+                        "Only virtual interfaces, or a channel subinterface with a channel ID assigned, may be "
+                        "assigned to a parent interface."
+                    )
+                })
 
         # Only one layer of channelization is permitted: an interface cannot be both channelized and a channel
         if self.channels and self.channel_id:
@@ -168,21 +182,23 @@ class InterfaceValidationMixin:
 
         # The channel type and channel_id are mutually dependent. The channel_id requirement is relaxed for a
         # replication base (bulk creation), where each channel_id is supplied per-instance during expansion.
-        is_channel = self.type == InterfaceTypeChoices.TYPE_CHANNEL
-        if is_channel and self.channel_id is None and not getattr(self, '_replicated_base', False):
+        if self.type == InterfaceTypeChoices.TYPE_CHANNEL and self.channel_id is None and not is_replicated_base:
             raise ValidationError({
                 'channel_id': _("Channel interfaces must have a channel ID assigned.")
             })
-        if self.channel_id is not None and not is_channel:
+        if self.channel_id is not None and not can_bind_to_channel:
             raise ValidationError({
-                'channel_id': _("A channel ID can be assigned only to a channel-type interface.")
+                'channel_id': _(
+                    "A channel ID cannot be assigned to a virtual, LAG, bridge, or wireless interface."
+                )
             })
 
-        # A channel subinterface must be bound to a channelized parent interface
-        if is_channel:
+        # A channel subinterface must be bound to a channelized parent. A replication base is checked too, so an
+        # invalid parent selection is caught before pattern expansion rather than per-instance.
+        if self.channel_id is not None or (is_replicated_base and can_bind_to_channel and self.parent_id):
             if self.parent is None:
                 raise ValidationError({
-                    'parent': _("Channel interfaces must be assigned to a parent interface.")
+                    'parent': _("A channel subinterface must be assigned to a parent interface.")
                 })
             if not self.parent.channels:
                 raise ValidationError({
@@ -197,9 +213,8 @@ class InterfaceValidationMixin:
                     ).format(channel_id=self.channel_id, channels=self.parent.channels)
                 })
 
-        # Reducing or clearing the channel count cannot orphan an existing channel subinterface bound to a higher
-        # channel (clearing channelization entirely would orphan every bound subinterface). Gated on the current or
-        # original channel count so the child lookup stays off the hot path for ordinary (never-channelized) interfaces.
+        # Reducing or clearing the channel count cannot orphan an existing child bound to a higher channel. Gated
+        # on channels/_original_channels so this stays off the hot path for never-channelized interfaces.
         if self.pk and (self.channels or self._original_channels):
             max_child_channel_id = self.child_interfaces.filter(
                 channel_id__gt=self.channels or 0
@@ -242,6 +257,95 @@ class InterfaceValidationMixin:
             raise ValidationError({'rf_role': _("Wireless role may be set only on wireless interfaces.")})
 
 
+class InterfaceChannelRenameMixin:
+    """
+    Cooperative __init__()/save() mixin for Interface and InterfaceTemplate: detects a rename of a channelized
+    parent and cascades it to any channel subinterface which follows the "<parent name>:<channel ID>" naming
+    convention.
+
+    Must precede the model's other bases so its __init__()/save() sit ahead of them in the MRO; both delegate
+    onward via super(), so a consuming model only needs to list this mixin first among its bases and call
+    super().__init__()/super().save() as usual -- no extra wiring required.
+    """
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self._original_name = self.__dict__.get('name')
+        # Also relied on by InterfaceValidationMixin.clean() (a channel-count reduction that would orphan an
+        # existing child). Tracked here rather than per-model so both concerns share one source of truth.
+        self._original_channels = self.__dict__.get('channels')
+
+    def save(self, *args, **kwargs):
+        update_fields = kwargs.get('update_fields')
+        # A save() whose update_fields excludes 'name'/'channels' won't actually persist that attribute, so the
+        # cascade decision below can't treat self.name/self.channels as current in that case -- fall back to the
+        # last known persisted value instead. Without this, e.g. clearing self.channels in memory and saving
+        # with update_fields=['name'] would see a falsy self.channels and skip a cascade the DB still requires.
+        name_persisted = update_fields is None or 'name' in update_fields
+        channels_persisted = update_fields is None or 'channels' in update_fields
+        is_channelized = self.channels if channels_persisted else self._original_channels
+        old_name, new_name = self._original_name, self.name
+        renamed = bool(self.pk and is_channelized and name_persisted and new_name != old_name)
+
+        super().save(*args, **kwargs)
+        # Captured after super().save() so it reflects the DB actually used -- which, when this save() was
+        # called with an explicit using=, is not necessarily what router.db_for_write() would return if
+        # re-run here.
+        db_alias = self._state.db
+
+        if name_persisted:
+            self._original_name = new_name
+        if channels_persisted:
+            self._original_channels = self.channels
+
+        if renamed:
+            # Defer until commit so a later save in the same transaction cannot overwrite the cascade.
+            transaction.on_commit(
+                lambda: self._rename_channel_subinterfaces(old_name, new_name, db_alias),
+                using=db_alias,
+            )
+
+    def _rename_channel_subinterfaces(self, old_name, new_name, db_alias):
+        """
+        Rename each channel subinterface following the "<parent name>:<channel ID>" convention to match this
+        interface's new name. A subinterface named otherwise is left untouched, as is one whose renamed form
+        would exceed the name field's max length or collide with an existing sibling.
+        """
+        max_name_length = self._meta.get_field('name').max_length
+        # This runs from an on_commit callback, after the triggering save()'s own transaction has already
+        # committed -- so without this outer atomic(), each child below would run in its own independent,
+        # auto-committing transaction rather than a savepoint, and an unexpected failure partway through
+        # could leave only some of the child set renamed.
+        with transaction.atomic(using=db_alias):
+            for child in self.child_interfaces.using(db_alias).filter(channel_id__isnull=False):
+                if child.name != f'{old_name}:{child.channel_id}':
+                    continue
+                candidate_name = f'{new_name}:{child.channel_id}'
+                if len(candidate_name) > max_name_length:
+                    continue
+                # A full save() (not a queryset update()) so _name, last_updated, and the changelog get updated
+                # too; a channel subinterface can never itself be channelized, so this can't recurse into the
+                # cascade. update_fields is restricted to what actually changed so unrelated receivers (e.g.
+                # Interface's own cable-path rebuild) can skip redundant work.
+                child.snapshot()
+                child.name = candidate_name
+                # Renamed in its own savepoint: the DB's unique constraint is the sole arbiter of a collision,
+                # and a collision on one child can't abort the rename of the others.
+                try:
+                    with transaction.atomic(using=db_alias):
+                        child.save(using=db_alias, update_fields=['name', '_name', 'last_updated'])
+                except IntegrityError:
+                    # Confirm this was really the expected name collision (not some other constraint) before
+                    # treating it as safe to skip. The (device, name) constraint is declared via
+                    # Meta.constraints, which validate_unique() does not check -- only validate_constraints()
+                    # does.
+                    try:
+                        child.validate_constraints()
+                    except ValidationError:
+                        continue
+                    raise
+
+
 class CoolingLoopValidationMixin:
     """
     Adds loop detection to the coolant chain formed by cooling intakes and outflows. A CoolingIntake is supplied

+ 41 - 6
netbox/dcim/signals.py

@@ -185,8 +185,13 @@ def nullify_connected_endpoints(instance, **kwargs):
         cablepath.retrace()
 
 
+# Fields this receiver reacts to. A save() whose update_fields is disjoint from this set (e.g. a plain rename)
+# cannot have touched channelization or cabling, so there's nothing for this receiver to do.
+_CHANNELIZATION_RELEVANT_FIELDS = frozenset({'channels', 'channel_id', 'parent', 'parent_id', 'cable', 'cable_id'})
+
+
 @receiver(post_save, sender=Interface)
-def update_channelized_cable_paths(instance, created, raw=False, **kwargs):
+def update_channelized_cable_paths(instance, created, raw=False, update_fields=None, **kwargs):
     """
     Rebuild cable paths when an interface's channelization changes without the Cable itself being modified: a channel
     subinterface is added, moved between parents, or has its channel_id changed, or channelization is toggled on an
@@ -194,37 +199,67 @@ def update_channelized_cable_paths(instance, created, raw=False, **kwargs):
     """
     if raw:
         return
+    if update_fields is not None and _CHANNELIZATION_RELEVANT_FIELDS.isdisjoint(update_fields):
+        return
 
     parent_ids = set()
 
-    # A channel subinterface was added, moved between parents, or had its channel_id changed
-    if instance.channel_id or instance._original_channel_id:
+    # A channel subinterface was added, moved between parents, or had its channel_id changed. Gated on an actual
+    # change (or creation) so a full re-save of an already-channelized child with neither field touched doesn't
+    # propagate cable state and rebuild the parent's paths for unrelated changes.
+    channelization_touched = (
+        created or instance.channel_id != instance._original_channel_id
+        or instance.parent_id != instance._original_parent_id
+    )
+    if channelization_touched and (instance.channel_id or instance._original_channel_id):
         parent_ids.update(pk for pk in (instance.parent_id, instance._original_parent_id) if pk)
 
     # Channelization was toggled on this interface while it carries a cable
     if instance.channels != instance._original_channels and instance.cable_id:
         parent_ids.add(instance.pk)
 
+    # Tracks whether anything below mutated instance's own row via a queryset/bulk operation (which bypasses
+    # this in-memory `instance`) rather than via save() -- see the refresh_from_db() call at the end.
+    own_row_mutated = False
+
     # select_related('cable') avoids a per-parent round-trip to fetch the Cable, which both
     # propagate_channel_cables() and rebuild_cable_paths() dereference. (Cable.profile is a plain field, not a
     # relation, so it needs no prefetching.)
     parents = Interface.objects.filter(pk__in=parent_ids, cable__isnull=False).select_related('cable')
     for parent in parents:
+        own_row_mutated = True
         if parent.channels:
             parent.propagate_channel_cables()
         rebuild_cable_paths(parent.cable)
 
-    # A channel subinterface whose parent no longer provides a cable must not retain stale mirrored cable attributes
-    if instance.channel_id and instance.cable_id:
-        parent = instance.parent
+    # A channel subinterface whose parent no longer provides a cable must not retain stale mirrored cable
+    # attributes -- including when it was just detached from channelization entirely (channel_id and/or parent
+    # cleared), since it then drops out of the old parent's propagation queryset above and would otherwise keep
+    # its old cable cache indefinitely.
+    if (instance.channel_id or instance._original_channel_id) and instance.cable_id:
+        parent = instance.parent if instance.channel_id else None
         if not (parent and parent.channels and parent.cable_id):
             Interface.objects.filter(pk=instance.pk).update(
                 cable=None, cable_end='', cable_connector=None, cable_positions=None
             )
+            own_row_mutated = True
             for cablepath in CablePath.objects.filter(_nodes__contains=instance):
                 if instance in cablepath.origins:
                     cablepath.delete()
 
+    # A channel child's own cable_id/cable_end/cable_connector/cable_positions/_path may have just been mutated
+    # at the DB level above -- mirrored from its parent (propagate_channel_cables(), which bulk_updates a
+    # separately-fetched copy of this same row), cleared (the queryset .update() above), or rewritten by
+    # rebuild_cable_paths()/CablePath.save()/.delete() (which set/clear _path on path origins via queryset
+    # .update(), also bypassing this in-memory `instance`) -- without touching this in-memory `instance`. A
+    # later full save() of this same instance by another caller in the same request (e.g.
+    # MACAddressShortcutMixin.update()'s second instance.save() for a combined mac_address change) would
+    # otherwise write those stale in-memory values back over what was just written. Gated on own_row_mutated so
+    # a full re-save of an already-consistent channel child (nothing channelization-related touched) doesn't
+    # pay for a refresh it doesn't need.
+    if own_row_mutated:
+        instance.refresh_from_db(fields=['cable', 'cable_end', 'cable_connector', 'cable_positions', '_path'])
+
     # Refresh the cached channelization state so that saving this same in-memory instance again compares against its
     # current values rather than re-triggering propagation from a stale baseline.
     instance._original_channels = instance.channels

+ 65 - 0
netbox/dcim/tests/test_api.py

@@ -3773,6 +3773,71 @@ class InterfaceTestCase(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTest
         self.assertEqual(iface.primary_mac_address.pk, mac2.pk)
         self.assertEqual(iface.mac_addresses.count(), mac_count_before)
 
+    def test_channel_binding_survives_combined_mac_address_update(self):
+        """
+        PATCHing channel_id/parent and mac_address together must not let the mac_address shortcut's
+        second instance.save() (see MACAddressShortcutMixin.update()) write the interface's
+        pre-propagation, stale in-memory cable_id/_path back over the cable and path just mirrored
+        from its newly assigned parent by update_channelized_cable_paths() -- and, on detach, must
+        not resurrect the stale in-memory values that update() clears via a queryset .update()
+        rather than a save() on this same instance.
+        """
+        self.add_permissions('dcim.change_interface', 'dcim.add_macaddress')
+        device = Device.objects.first()
+        channelized_parent = Interface.objects.get(device=device, name='Interface 3')
+        far_end = Interface.objects.create(device=device, name='Far End', type='1000base-t')
+        cable = Cable(
+            profile=CableProfileChoices.BREAKOUT_1C4P_4C1P,
+            a_terminations=[channelized_parent],
+            b_terminations=[far_end],
+        )
+        cable.full_clean()
+        cable.save()
+
+        child = Interface.objects.get(device=device, name='Interface 1')
+        url = self._get_detail_url(child)
+
+        # Attach: bind the channel and set mac_address in the same request.
+        data = {
+            'parent': channelized_parent.pk,
+            'channel_id': 1,
+            'type': 'channel',
+            'mac_address': 'AA:BB:CC:DD:EE:01',
+        }
+        response = self.client.patch(url, data, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+
+        child.refresh_from_db()
+        self.assertEqual(
+            child.cable_id, cable.pk,
+            "the mac_address update's second save() clobbered the cable just mirrored from the parent",
+        )
+        self.assertEqual(child.cable_positions, [1])
+        self.assertIsNotNone(
+            child._path_id, "the mac_address update's second save() clobbered the path traced on attach",
+        )
+        self.assertIsNotNone(child.primary_mac_address)
+        self.assertEqual(str(child.primary_mac_address.mac_address).upper(), 'AA:BB:CC:DD:EE:01')
+
+        # Detach: clear the channel binding and change mac_address again in the same request.
+        data = {
+            'parent': None,
+            'channel_id': None,
+            'type': '1000base-t',
+            'mac_address': 'AA:BB:CC:DD:EE:02',
+        }
+        response = self.client.patch(url, data, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+
+        child.refresh_from_db()
+        self.assertIsNone(
+            child.cable_id, "the mac_address update's second save() resurrected the cleared cable on detach",
+        )
+        self.assertIsNone(
+            child._path_id, "the mac_address update's second save() resurrected the cleared path on detach",
+        )
+        self.assertEqual(str(child.primary_mac_address.mac_address).upper(), 'AA:BB:CC:DD:EE:02')
+
 
 class FrontPortTestCase(APIViewTestCases.APIViewTestCase):
     model = FrontPort

+ 677 - 29
netbox/dcim/tests/test_channelization.py

@@ -1,8 +1,18 @@
+import json
+from unittest import mock
+
+from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ValidationError
-from django.test import TestCase
+from django.db import connection, router
+from django.test import Client, TestCase, TransactionTestCase
+from django.test.utils import CaptureQueriesContext
 from django.urls import reverse
+from rest_framework.test import APIClient
 
+from core.choices import ObjectChangeActionChoices
+from core.models import ObjectChange
 from dcim.choices import CableProfileChoices, InterfaceTypeChoices
+from dcim.filtersets import InterfaceFilterSet
 from dcim.models import (
     Cable,
     CablePath,
@@ -17,6 +27,9 @@ from dcim.models import (
 from dcim.svg import CableTraceSVG
 from dcim.svg.cables import Connector
 from dcim.tests.utils import BaseCablePathTestCase
+from users.constants import TOKEN_PREFIX
+from users.models import Token, User
+from utilities.ordering import naturalize_interface
 from utilities.testing import TestCase as ViewTestCase
 
 
@@ -342,10 +355,126 @@ class ChannelizedCablePathTestCase(BaseCablePathTestCase):
         self.assertPathDoesNotExist((channel, cable, far[0]))
         self.assertPathDoesNotExist((far[0], cable, channel))
 
+    def _rename_cabled_channelized_pair(self, device_suffix, channel_count):
+        """
+        Build a cabled pair of channelized interfaces with the given channel count, rename the near parent, and
+        return (query_count, near_channels, far_channels, cable) for the caller to assert against.
+        """
+        far_device = Device.objects.create(
+            site=self.site, device_type=self.device.device_type, role=self.device.role,
+            name=f'Device {device_suffix}'
+        )
+        near_parent, near_channels = self._create_channelized_interface(f'et{device_suffix}', channel_count)
+        far_parent, far_channels = self._create_channelized_interface(
+            f'et{device_suffix}', channel_count, device=far_device
+        )
+        profile = {2: CableProfileChoices.SINGLE_1C2P, 8: CableProfileChoices.SINGLE_1C8P}[channel_count]
+        cable = Cable(profile=profile, a_terminations=[near_parent], b_terminations=[far_parent])
+        cable.clean()
+        cable.save()
+
+        near_parent.refresh_from_db()
+        with CaptureQueriesContext(connection) as ctx:
+            near_parent.name = f'ex{device_suffix}'
+            with self.captureOnCommitCallbacks(execute=True):
+                near_parent.save()
+
+        return len(ctx.captured_queries), near_channels, far_channels, cable
+
+    def test_111_rename_cabled_parent_preserves_cable_paths_without_quadratic_cost(self):
+        """
+        Renaming a cabled channelized parent must cascade the children's names without disturbing their cable
+        paths, and without re-deriving cable state per child (which would make the rename quadratic in the
+        channel count). Pinned by comparing query cost at 2 vs. 8 channels: linear per-child work scales with
+        the 4x channel growth; a quadratic regression would blow well past it.
+        """
+        queries_2ch, near_channels_2ch, far_channels_2ch, cable_2ch = self._rename_cabled_channelized_pair('A', 2)
+        queries_8ch, near_channels_8ch, far_channels_8ch, cable_8ch = self._rename_cabled_channelized_pair('B', 8)
+
+        self.assertLess(
+            queries_8ch, queries_2ch * 4,
+            "Renaming an 8-channel cabled parent cost disproportionately more than a 2-channel one; check "
+            "whether update_channelized_cable_paths is re-running a full cable/path rebuild per renamed child."
+        )
+
+        # Both the 2- and 8-channel cascades must have actually renamed and preserved paths correctly; checking
+        # only the query count above would still pass if the larger (8-channel) cascade silently did neither.
+        for prefix, near_channels, far_channels, cable in (
+            ('exA:', near_channels_2ch, far_channels_2ch, cable_2ch),
+            ('exB:', near_channels_8ch, far_channels_8ch, cable_8ch),
+        ):
+            for near, far in zip(near_channels, far_channels):
+                near.refresh_from_db()
+                self.assertTrue(near.name.startswith(prefix))
+                self.assertEqual(near.cable_id, cable.pk)
+                self.assertPathExists((near, cable, far), is_complete=True, is_active=True)
+                self.assertPathExists((far, cable, near), is_complete=True, is_active=True)
+
+    def test_112_full_resave_of_unchanged_channel_child_skips_propagation(self):
+        """
+        A full re-save of an already-channelized child with neither channel_id nor parent actually changed must
+        not re-propagate cable state or rebuild the parent's paths; previously only update_fields-excluded
+        partial saves were guarded, so a full save of an unrelated field still passed through.
+        """
+        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()
+
+        channel = channels[0]
+        channel.refresh_from_db()
+        channel.description = 'updated'
+        with (
+            mock.patch.object(Interface, 'propagate_channel_cables') as mock_propagate,
+            mock.patch('dcim.signals.rebuild_cable_paths') as mock_rebuild,
+        ):
+            channel.save()
+
+        mock_propagate.assert_not_called()
+        mock_rebuild.assert_not_called()
+
+    def test_113_detach_channel_clears_stale_cable_attributes(self):
+        """
+        Fully detaching a channel subinterface (clearing both parent and channel_id) must clear its mirrored
+        cable attributes too -- once detached, it drops out of the old parent's propagation queryset and would
+        otherwise retain a stale cable_id indefinitely.
+        """
+        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()
+
+        channel = channels[0]
+        channel.refresh_from_db()
+        self.assertEqual(channel.cable_id, cable.pk)
+
+        channel.parent = None
+        channel.channel_id = None
+        channel.type = InterfaceTypeChoices.TYPE_10GE_SFP_PLUS
+        channel.full_clean()
+        channel.save()
 
-class ChannelizedInterfaceValidationTestCase(TestCase):
+        channel.refresh_from_db()
+        self.assertIsNone(channel.cable_id)
+        self.assertIsNone(channel.cable_connector)
+        self.assertIsNone(channel.cable_positions)
+        self.assertPathIsNotSet(channel)
+
+
+class ChannelizedInterfaceTestCase(TestCase):
     """
-    Test validation of the channels and channel_id fields on Interface.
+    Test validation, properties, renaming, and REST/GraphQL filtering of channelized Interfaces and their channel
+    subinterfaces. Cable-path and bulk-view coverage remain in their own specialized TestCase classes below;
+    commit-dependent cascade side effects remain in the separate ChannelizedInterfaceRenameSideEffectsTestCase
+    (a TransactionTestCase).
     """
 
     @classmethod
@@ -359,6 +488,23 @@ class ChannelizedInterfaceValidationTestCase(TestCase):
             device=cls.device, name='et0', type=InterfaceTypeChoices.TYPE_40GE_QSFP_PLUS, channels=4
         )
 
+        # A second, isolated device for the kind=physical filter tests further below, so their pre-built channel
+        # subinterface doesn't collide with the many ad hoc channel_id=1 children the tests above create against
+        # cls.parent.
+        cls.filter_device = Device.objects.create(site=site, device_type=device_type, role=role, name='Device 2')
+        cls.filter_parent = Interface.objects.create(
+            device=cls.filter_device, name='ft0', type=InterfaceTypeChoices.TYPE_40GE_QSFP_PLUS, channels=1
+        )
+        cls.filter_channel = Interface.objects.create(
+            device=cls.filter_device, name='ft0:1', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS,
+            parent=cls.filter_parent, channel_id=1
+        )
+        cls.filter_plain = Interface.objects.create(
+            device=cls.filter_device, name='fx0', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS
+        )
+
+    # -- validation --------------------------------------------------------------------------------------------
+
     def test_valid_channel_subinterface(self):
         interface = Interface(
             device=self.device, name='et0:1', type=InterfaceTypeChoices.TYPE_CHANNEL, parent=self.parent, channel_id=1
@@ -372,14 +518,69 @@ class ChannelizedInterfaceValidationTestCase(TestCase):
         with self.assertRaises(ValidationError):
             interface.full_clean()
 
-    def test_channel_id_requires_channel_type(self):
+    def test_channel_id_allowed_on_specific_physical_type(self):
+        # A channel subinterface may keep its own specific physical type (e.g. to record the actual transceiver
+        # in use) instead of the generic "channel" type.
         interface = Interface(
             device=self.device, name='et0:1', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS,
             parent=self.parent, channel_id=1
         )
+        interface.full_clean()  # Should not raise
+
+    def test_channel_id_rejected_on_virtual_type(self):
+        interface = Interface(
+            device=self.device, name='et0:1', type=InterfaceTypeChoices.TYPE_VIRTUAL,
+            parent=self.parent, channel_id=1
+        )
+        with self.assertRaises(ValidationError):
+            interface.full_clean()
+
+    def test_physical_type_parent_requires_channel_id(self):
+        # A physical interface type may not simply be assigned a parent without also being bound to a channel
+        interface = Interface(
+            device=self.device, name='et0:1', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS, parent=self.parent
+        )
         with self.assertRaises(ValidationError):
             interface.full_clean()
 
+    def test_channel_id_rejected_on_lag_type(self):
+        interface = Interface(
+            device=self.device, name='et0:1', type=InterfaceTypeChoices.TYPE_LAG, parent=self.parent, channel_id=1
+        )
+        with self.assertRaises(ValidationError):
+            interface.full_clean()
+
+    def test_channel_subinterface_with_physical_type_is_not_wired(self):
+        # A channel subinterface derives its cable from its parent and cannot be cabled directly, regardless of
+        # whether it uses the generic "channel" type or its own specific physical type.
+        interface = Interface.objects.create(
+            device=self.device, name='et0:1', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS,
+            parent=self.parent, channel_id=1
+        )
+        self.assertFalse(interface.is_wired)
+
+    def test_channel_subinterface_with_physical_type_is_channel(self):
+        # is_channel is identified by channel_id, not by type, so it must agree with is_wired for a channel
+        # subinterface that keeps its own specific physical type.
+        interface = Interface.objects.create(
+            device=self.device, name='et0:1', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS,
+            parent=self.parent, channel_id=1
+        )
+        self.assertTrue(interface.is_channel)
+
+    def test_generic_channel_type_is_channel(self):
+        interface = Interface.objects.create(
+            device=self.device, name='et0:2', type=InterfaceTypeChoices.TYPE_CHANNEL,
+            parent=self.parent, channel_id=2
+        )
+        self.assertTrue(interface.is_channel)
+
+    def test_non_channel_interface_is_not_channel(self):
+        interface = Interface.objects.create(
+            device=self.device, name='xe0', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS
+        )
+        self.assertFalse(interface.is_channel)
+
     def test_channel_requires_parent(self):
         interface = Interface(
             device=self.device, name='et0:1', type=InterfaceTypeChoices.TYPE_CHANNEL, channel_id=1
@@ -452,11 +653,318 @@ class ChannelizedInterfaceValidationTestCase(TestCase):
         with self.assertRaises(ValidationError):
             duplicate.full_clean()
 
+    def test_channel_id_rejected_on_interface_with_existing_cable_termination(self):
+        # A channel subinterface's cable state is mirrored from its parent; an interface that already carries its
+        # own direct cable connection cannot also be converted into one.
+        interface = Interface.objects.create(
+            device=self.device, name='xe0', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS
+        )
+        far = Interface.objects.create(
+            device=self.device, name='xe1', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS
+        )
+        cable = Cable(a_terminations=[interface], b_terminations=[far])
+        cable.clean()
+        cable.save()
+
+        interface.refresh_from_db()
+        interface.parent = self.parent
+        interface.channel_id = 1
+        with self.assertRaises(ValidationError):
+            interface.full_clean()
+
+    # -- renaming ----------------------------------------------------------------------------------------------
+    # Renaming a channelized parent interface updates the names of any channel subinterfaces which follow the
+    # "<parent name>:<channel ID>" convention.
+
+    def test_rename_updates_conforming_children(self):
+        child = Interface.objects.create(
+            device=self.device, name='et0:1', type=InterfaceTypeChoices.TYPE_CHANNEL, parent=self.parent, channel_id=1
+        )
+
+        self.parent.name = 'et1'
+        with self.captureOnCommitCallbacks(execute=True):
+            self.parent.save()
+
+        child.refresh_from_db()
+        self.assertEqual(child.name, 'et1:1')
+
+    def test_rename_cascade_uses_save_state_db_not_router(self):
+        # The deferred callback and child query/save must reuse self._state.db (the DB actually used by
+        # save()), not re-invoke router.db_for_write() -- which could differ from an explicit save(using=...).
+        # Django's own base Model.save() legitimately consults the router once per plain save() call (when no
+        # explicit using= is given); the pre-fix mixin code consulted it twice more for the same instance during
+        # the cascade. Spy on calls for the Interface model specifically to confirm only that one call remains.
+        child = Interface.objects.create(
+            device=self.device, name='et0:1', type=InterfaceTypeChoices.TYPE_CHANNEL, parent=self.parent, channel_id=1
+        )
+
+        self.parent.name = 'et1'
+        real_db_for_write = router.db_for_write
+        calls = []
+
+        def spy(model, **hints):
+            if model is Interface:
+                calls.append(model)
+            return real_db_for_write(model, **hints)
+
+        with mock.patch('django.db.router.db_for_write', side_effect=spy):
+            with self.captureOnCommitCallbacks(execute=True):
+                self.parent.save()
+
+        child.refresh_from_db()
+        self.assertEqual(child.name, 'et1:1')
+        self.assertEqual(
+            len(calls), 1,
+            "router.db_for_write(Interface) was consulted more than once; the rename cascade should reuse "
+            "self._state.db instead of re-invoking the router."
+        )
+
+    def test_rename_leaves_nonconforming_children_untouched(self):
+        child = Interface.objects.create(
+            device=self.device, name='et0-custom', type=InterfaceTypeChoices.TYPE_CHANNEL, parent=self.parent,
+            channel_id=1
+        )
+
+        self.parent.name = 'et1'
+        with self.captureOnCommitCallbacks(execute=True):
+            self.parent.save()
+
+        child.refresh_from_db()
+        self.assertEqual(child.name, 'et0-custom')
+
+    def test_rename_skips_child_on_collision(self):
+        colliding_child = Interface.objects.create(
+            device=self.device, name='et0:1', type=InterfaceTypeChoices.TYPE_CHANNEL, parent=self.parent, channel_id=1
+        )
+        Interface.objects.create(device=self.device, name='et1:1', type=InterfaceTypeChoices.TYPE_VIRTUAL)
+
+        self.parent.name = 'et1'
+        with self.captureOnCommitCallbacks(execute=True):
+            self.parent.save()
+
+        colliding_child.refresh_from_db()
+        self.assertEqual(colliding_child.name, 'et0:1')
+
+    def test_rename_collision_on_one_child_does_not_block_others(self):
+        # colliding_child conforms to the naming convention, so it reaches save() and genuinely hits
+        # IntegrityError; a collision there must not block the other, non-colliding child's rename.
+        colliding_child = Interface.objects.create(
+            device=self.device, name='et0:1', type=InterfaceTypeChoices.TYPE_CHANNEL, parent=self.parent, channel_id=1
+        )
+        clear_child = Interface.objects.create(
+            device=self.device, name='et0:2', type=InterfaceTypeChoices.TYPE_CHANNEL, parent=self.parent, channel_id=2
+        )
+        Interface.objects.create(device=self.device, name='et1:1', type=InterfaceTypeChoices.TYPE_VIRTUAL)
+
+        self.parent.name = 'et1'
+        with self.captureOnCommitCallbacks(execute=True):
+            self.parent.save()
+
+        colliding_child.refresh_from_db()
+        clear_child.refresh_from_db()
+        self.assertEqual(colliding_child.name, 'et0:1')
+        self.assertEqual(clear_child.name, 'et1:2')
+
+    def test_rename_cascade_is_deferred_until_transaction_commits(self):
+        # A sibling object saved later in the same transaction (e.g. by a bulk view) must not be able to
+        # silently undo the cascade by writing back a stale in-memory copy of the child's name.
+        child = Interface.objects.create(
+            device=self.device, name='et0:1', type=InterfaceTypeChoices.TYPE_CHANNEL, parent=self.parent, channel_id=1
+        )
+
+        with self.captureOnCommitCallbacks(execute=False) as callbacks:
+            self.parent.name = 'et1'
+            self.parent.save()
+
+            # Deferred until "commit" (running the captured callbacks below): not yet propagated.
+            child.refresh_from_db()
+            self.assertEqual(child.name, 'et0:1')
+
+            # Simulate a sibling's own save() in the same batch, re-asserting the child's stale name — exactly
+            # what BulkRenameView does when the same child is also selected in a bulk rename.
+            stale_copy = Interface.objects.get(pk=child.pk)
+            stale_copy.save()
+
+        # The deferred cascade is the last write once the transaction commits: still renames the child.
+        for callback in callbacks:
+            callback()
+        child.refresh_from_db()
+        self.assertEqual(child.name, 'et1:1')
+
+    def test_rename_of_non_channelized_interface_is_a_no_op(self):
+        plain = Interface.objects.create(
+            device=self.device, name='xe0', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS
+        )
+        plain.name = 'xe1'
+        plain.save()  # Should not raise despite having no channel subinterfaces to check
+
+    def test_rename_then_channelize_then_rename_again(self):
+        # Renaming while channels is unset, then channelizing, then renaming again must correctly cascade the
+        # second rename to any child created in between.
+        interface = Interface.objects.create(
+            device=self.device, name='zz0', type=InterfaceTypeChoices.TYPE_40GE_QSFP_PLUS
+        )
+        interface.name = 'zz1'
+        interface.save()  # Not yet channelized: no cascade, but _original_name must become 'zz1'
+
+        interface.channels = 4
+        interface.save()
+        child = Interface.objects.create(
+            device=self.device, name='zz1:1', type=InterfaceTypeChoices.TYPE_CHANNEL, parent=interface, channel_id=1
+        )
+
+        interface.name = 'zz2'
+        with self.captureOnCommitCallbacks(execute=True):
+            interface.save()
+
+        child.refresh_from_db()
+        self.assertEqual(child.name, 'zz2:1')
+
+    def test_original_name_is_set_for_an_instance_built_without_a_name_kwarg(self):
+        # An instance constructed without passing name= (so __init__ caches _original_name as None) must still
+        # cascade correctly once a name and channels are assigned and it's saved for the first time.
+        interface = Interface(device=self.device, type=InterfaceTypeChoices.TYPE_40GE_QSFP_PLUS)
+        interface.name = 'zz0'
+        interface.channels = 4
+        interface.save()
+        child = Interface.objects.create(
+            device=self.device, name='zz0:1', type=InterfaceTypeChoices.TYPE_CHANNEL, parent=interface, channel_id=1
+        )
+
+        interface.name = 'zz1'
+        with self.captureOnCommitCallbacks(execute=True):
+            interface.save()
+
+        child.refresh_from_db()
+        self.assertEqual(child.name, 'zz1:1')
+
+    def test_save_with_update_fields_excluding_name_does_not_cascade(self):
+        # A save() that explicitly excludes 'name' from update_fields does not persist the in-memory name change,
+        # so it must not cascade a rename to children, nor treat that unpersisted name as the new baseline.
+        child = Interface.objects.create(
+            device=self.device, name='et0:1', type=InterfaceTypeChoices.TYPE_CHANNEL, parent=self.parent, channel_id=1
+        )
+
+        self.parent.name = 'et1'
+        with self.captureOnCommitCallbacks(execute=True):
+            self.parent.save(update_fields=['description'])
+
+        self.parent.refresh_from_db()
+        child.refresh_from_db()
+        self.assertEqual(self.parent.name, 'et0')  # Not persisted
+        self.assertEqual(child.name, 'et0:1')  # Not cascaded
+
+    def test_update_fields_excluding_name_does_not_desync_later_full_rename(self):
+        # A later full save() must still correctly cascade, proving the earlier partial save didn't refresh
+        # _original_name to its unpersisted in-memory value.
+        child = Interface.objects.create(
+            device=self.device, name='et0:1', type=InterfaceTypeChoices.TYPE_CHANNEL, parent=self.parent, channel_id=1
+        )
+
+        self.parent.name = 'et1'
+        self.parent.save(update_fields=['description'])  # Not persisted; DB name is still 'et0'
+
+        with self.captureOnCommitCallbacks(execute=True):
+            self.parent.save()  # Full save: persists 'et1', cascading from the true prior (DB) name 'et0'
+
+        child.refresh_from_db()
+        self.assertEqual(child.name, 'et1:1')
+
+    # -- kind=physical filtering ---------------------------------------------------------------------------------
+    # A channel subinterface is excluded from kind=physical (REST) / kind: PHYSICAL (GraphQL), even when it keeps
+    # its own specific physical type rather than the generic "channel" type -- matching Interface.is_wired, since
+    # it derives its cable from its channelized parent and cannot be cabled directly.
+
+    def test_rest_kind_physical_excludes_channel_subinterface(self):
+        filterset = InterfaceFilterSet({'kind': 'physical'}, Interface.objects.all())
+        results = set(filterset.qs.values_list('pk', flat=True))
+        self.assertIn(self.filter_parent.pk, results)
+        self.assertIn(self.filter_plain.pk, results)
+        self.assertNotIn(self.filter_channel.pk, results)
+
+    def test_graphql_kind_physical_excludes_channel_subinterface(self):
+        user = User.objects.create_user(username='testuser', is_superuser=True)
+        client = Client()
+        client.force_login(user)
+
+        query = '{ interface_list(filters: {kind: KIND_PHYSICAL}) { id } }'
+        response = client.post(
+            reverse('graphql'), data=json.dumps({'query': query}), content_type='application/json'
+        )
+        self.assertEqual(response.status_code, 200)
+        data = json.loads(response.content)
+        self.assertNotIn('errors', data)
+        result_ids = {int(r['id']) for r in data['data']['interface_list']}
+        self.assertIn(self.filter_parent.pk, result_ids)
+        self.assertIn(self.filter_plain.pk, result_ids)
+        self.assertNotIn(self.filter_channel.pk, result_ids)
+
+
+class ChannelizedInterfaceRenameSideEffectsTestCase(TransactionTestCase):
+    """
+    Test that a cascaded channel subinterface rename behaves as a full save() (updating _name and last_updated,
+    and recording an ObjectChange), not merely as a raw name update. Uses TransactionTestCase, not TestCase, so
+    the request's transaction really commits and on_commit() fires inline as in production — under TestCase the
+    whole test runs inside one uncommitted transaction, and captureOnCommitCallbacks() would only fire the
+    deferred rename after the request (and its changelog's current_request context) has already torn down.
+    """
+
+    def setUp(self):
+        self.user = User.objects.create_user(username='testuser', is_superuser=True)
+        self.token = Token.objects.create(user=self.user)
+        self.header = {'HTTP_AUTHORIZATION': f'Bearer {TOKEN_PREFIX}{self.token.key}.{self.token.token}'}
+        self.client = APIClient()
+
+        manufacturer = Manufacturer.objects.create(name='Generic', slug='generic')
+        device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Test Device')
+        role = DeviceRole.objects.create(name='Device Role', slug='device-role')
+        site = Site.objects.create(name='Site', slug='site')
+        self.device = Device.objects.create(site=site, device_type=device_type, role=role, name='Device 1')
+        self.parent = Interface.objects.create(
+            device=self.device, name='et0', type=InterfaceTypeChoices.TYPE_40GE_QSFP_PLUS, channels=4
+        )
+        self.child = Interface.objects.create(
+            device=self.device, name='et0:1', type=InterfaceTypeChoices.TYPE_CHANNEL, parent=self.parent,
+            channel_id=1
+        )
+
+    def _rename_parent(self):
+        url = reverse('dcim-api:interface-detail', kwargs={'pk': self.parent.pk})
+        response = self.client.patch(url, {'name': 'et1'}, format='json', **self.header)
+        self.assertEqual(response.status_code, 200, response.data)
+
+    def test_rename_updates_child_name_ordering_field(self):
+        self._rename_parent()
+
+        self.child.refresh_from_db()
+        self.assertEqual(self.child.name, 'et1:1')
+        self.assertEqual(self.child._name, naturalize_interface('et1:1', max_length=100))
+
+    def test_rename_bumps_child_last_updated(self):
+        original_last_updated = self.child.last_updated
+
+        self._rename_parent()
+
+        self.child.refresh_from_db()
+        self.assertGreater(self.child.last_updated, original_last_updated)
+
+    def test_rename_records_child_changelog_entry(self):
+        self._rename_parent()
+
+        objectchange = ObjectChange.objects.filter(
+            action=ObjectChangeActionChoices.ACTION_UPDATE,
+            changed_object_type=ContentType.objects.get_for_model(Interface),
+            changed_object_id=self.child.pk,
+        ).first()
+        self.assertIsNotNone(objectchange, "No ObjectChange was recorded for the cascaded child rename")
+        self.assertEqual(objectchange.prechange_data['name'], 'et0:1')
+        self.assertEqual(objectchange.postchange_data['name'], 'et1:1')
+
 
 class ChannelizedInterfaceTemplateTestCase(TestCase):
     """
-    Test that the channels, channel_id, and parent fields are replicated from InterfaceTemplate to the Interfaces
-    instantiated for a new Device, and that parent interfaces are populated before their channel subinterfaces.
+    Test validation, instantiation-time replication, and renaming of channelized InterfaceTemplates and their
+    channel subinterface templates.
     """
 
     @classmethod
@@ -465,23 +973,34 @@ class ChannelizedInterfaceTemplateTestCase(TestCase):
         cls.device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Test Device', slug='test-device')
         cls.role = DeviceRole.objects.create(name='Device Role', slug='device-role')
         cls.site = Site.objects.create(name='Site', slug='site')
-
-        # A channelized parent template broken out into four channel subinterface templates bound to it
-        parent_template = InterfaceTemplate.objects.create(
+        cls.parent = InterfaceTemplate.objects.create(
             device_type=cls.device_type, name='et0', type=InterfaceTypeChoices.TYPE_40GE_QSFP_PLUS, channels=4
         )
+
+        # A second, isolated device type with its own pre-built channel subinterface templates, for the
+        # instantiation-replication test below -- so its four pre-existing channel_id 1-4 children don't collide
+        # with the many ad hoc children the validation/rename tests create against cls.parent.
+        cls.replication_device_type = DeviceType.objects.create(
+            manufacturer=manufacturer, model='Replication Device', slug='replication-device'
+        )
+        replication_parent = InterfaceTemplate.objects.create(
+            device_type=cls.replication_device_type, name='et0', type=InterfaceTypeChoices.TYPE_40GE_QSFP_PLUS,
+            channels=4
+        )
         for i in range(1, 5):
             InterfaceTemplate.objects.create(
-                device_type=cls.device_type,
+                device_type=cls.replication_device_type,
                 name=f'et0:{i}',
                 type=InterfaceTypeChoices.TYPE_CHANNEL,
-                parent=parent_template,
+                parent=replication_parent,
                 channel_id=i,
             )
 
+    # -- instantiation-time replication -------------------------------------------------------------------------
+
     def test_channelization_replicated_on_instantiation(self):
         device = Device.objects.create(
-            site=self.site, device_type=self.device_type, role=self.role, name='Device 1'
+            site=self.site, device_type=self.replication_device_type, role=self.role, name='Device 1'
         )
 
         # The channelized parent carries its channel count
@@ -496,35 +1015,46 @@ class ChannelizedInterfaceTemplateTestCase(TestCase):
             self.assertIsNone(channel.channels)
             self.assertEqual(channel.parent, parent)
 
+    # -- validation ----------------------------------------------------------------------------------------------
+
     def test_parent_template_validation(self):
         # A parent template must belong to the same device type
         other_type = DeviceType.objects.create(
             manufacturer=self.device_type.manufacturer, model='Other Device', slug='other-device'
         )
-        foreign_parent = InterfaceTemplate.objects.get(device_type=self.device_type, name='et0')
         template = InterfaceTemplate(
             device_type=other_type, name='et0:1', type=InterfaceTypeChoices.TYPE_CHANNEL,
-            parent=foreign_parent, channel_id=1
+            parent=self.parent, channel_id=1
         )
         with self.assertRaises(ValidationError):
             template.full_clean()
 
+    def test_template_channel_id_allowed_on_specific_physical_type(self):
+        # A channel subinterface template may keep its own specific physical type (e.g. to record the actual
+        # transceiver in use) instead of the generic "channel" type.
+        template = InterfaceTemplate(
+            device_type=self.device_type, name='et0:1', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS,
+            parent=self.parent, channel_id=1
+        )
+        template.full_clean()  # Should not raise
+
     def test_template_parent_channel_id_must_be_unique(self):
-        parent = InterfaceTemplate.objects.get(device_type=self.device_type, name='et0')
-        # Channel 1 already exists on the parent (created in setUpTestData)
+        InterfaceTemplate.objects.create(
+            device_type=self.device_type, name='et0:1', type=InterfaceTypeChoices.TYPE_CHANNEL,
+            parent=self.parent, channel_id=1
+        )
         duplicate = InterfaceTemplate(
             device_type=self.device_type, name='et0:1b', type=InterfaceTypeChoices.TYPE_CHANNEL,
-            parent=parent, channel_id=1
+            parent=self.parent, channel_id=1
         )
         with self.assertRaises(ValidationError):
             duplicate.full_clean()
 
     def test_template_channel_id_within_parent_range(self):
         # A channel_id beyond the parent's channel count is rejected at the template level
-        parent = InterfaceTemplate.objects.get(device_type=self.device_type, name='et0')
         template = InterfaceTemplate(
             device_type=self.device_type, name='et0:5', type=InterfaceTypeChoices.TYPE_CHANNEL,
-            parent=parent, channel_id=5
+            parent=self.parent, channel_id=5
         )
         with self.assertRaises(ValidationError):
             template.full_clean()
@@ -541,8 +1071,8 @@ class ChannelizedInterfaceTemplateTestCase(TestCase):
         with self.assertRaises(ValidationError):
             template.full_clean()
 
-    def test_template_channel_id_requires_channel_type(self):
-        # A channel_id on a non-channel-type template is rejected
+    def test_template_channel_id_requires_parent(self):
+        # A channel_id with no parent assigned is rejected, regardless of type
         template = InterfaceTemplate(
             device_type=self.device_type, name='xe1', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS,
             channel_id=1
@@ -551,19 +1081,137 @@ class ChannelizedInterfaceTemplateTestCase(TestCase):
             template.full_clean()
 
     def test_template_reduce_channels_below_bound_child_rejected(self):
-        # Reducing a parent template's channel count below a bound child template's channel_id is rejected (channels
-        # 3 & 4 are bound in setUpTestData)
-        parent = InterfaceTemplate.objects.get(device_type=self.device_type, name='et0')
-        parent.channels = 2
+        # Bind a channel to the highest channel of the parent, then attempt to reduce the parent's channel count
+        InterfaceTemplate.objects.create(
+            device_type=self.device_type, name='et0:4', type=InterfaceTypeChoices.TYPE_CHANNEL,
+            parent=self.parent, channel_id=4
+        )
+        self.parent.channels = 2
         with self.assertRaises(ValidationError):
-            parent.full_clean()
+            self.parent.full_clean()
 
     def test_template_clear_channels_with_bound_child_rejected(self):
         # De-channelizing a parent template entirely is rejected while a channel subinterface template is bound to it
-        parent = InterfaceTemplate.objects.get(device_type=self.device_type, name='et0')
-        parent.channels = None
+        InterfaceTemplate.objects.create(
+            device_type=self.device_type, name='et0:1', type=InterfaceTypeChoices.TYPE_CHANNEL,
+            parent=self.parent, channel_id=1
+        )
+        self.parent.channels = None
         with self.assertRaises(ValidationError):
-            parent.full_clean()
+            self.parent.full_clean()
+
+    # -- renaming ------------------------------------------------------------------------------------------------
+    # Renaming a channelized parent InterfaceTemplate updates the names of any channel subinterface templates
+    # which follow the "<parent name>:<channel ID>" convention.
+
+    def test_rename_updates_conforming_children(self):
+        child = InterfaceTemplate.objects.create(
+            device_type=self.device_type, name='et0:1', type=InterfaceTypeChoices.TYPE_CHANNEL,
+            parent=self.parent, channel_id=1
+        )
+
+        self.parent.name = 'et1'
+        with self.captureOnCommitCallbacks(execute=True):
+            self.parent.save()
+
+        child.refresh_from_db()
+        self.assertEqual(child.name, 'et1:1')
+
+    def test_rename_leaves_nonconforming_children_untouched(self):
+        child = InterfaceTemplate.objects.create(
+            device_type=self.device_type, name='et0-custom', type=InterfaceTypeChoices.TYPE_CHANNEL,
+            parent=self.parent, channel_id=1
+        )
+
+        self.parent.name = 'et1'
+        with self.captureOnCommitCallbacks(execute=True):
+            self.parent.save()
+
+        child.refresh_from_db()
+        self.assertEqual(child.name, 'et0-custom')
+
+    def test_rename_skips_child_on_collision(self):
+        colliding_child = InterfaceTemplate.objects.create(
+            device_type=self.device_type, name='et0:1', type=InterfaceTypeChoices.TYPE_CHANNEL,
+            parent=self.parent, channel_id=1
+        )
+        InterfaceTemplate.objects.create(
+            device_type=self.device_type, name='et1:1', type=InterfaceTypeChoices.TYPE_VIRTUAL
+        )
+
+        self.parent.name = 'et1'
+        with self.captureOnCommitCallbacks(execute=True):
+            self.parent.save()
+
+        colliding_child.refresh_from_db()
+        self.assertEqual(colliding_child.name, 'et0:1')
+
+    def test_rename_does_not_collide_across_device_types(self):
+        # A same-named channel subinterface template under a different device type must not block the rename
+        other_type = DeviceType.objects.create(
+            manufacturer=self.device_type.manufacturer, model='Other Device', slug='other-device'
+        )
+        InterfaceTemplate.objects.create(
+            device_type=other_type, name='et1:1', type=InterfaceTypeChoices.TYPE_VIRTUAL
+        )
+        child = InterfaceTemplate.objects.create(
+            device_type=self.device_type, name='et0:1', type=InterfaceTypeChoices.TYPE_CHANNEL,
+            parent=self.parent, channel_id=1
+        )
+
+        self.parent.name = 'et1'
+        with self.captureOnCommitCallbacks(execute=True):
+            self.parent.save()
+
+        child.refresh_from_db()
+        self.assertEqual(child.name, 'et1:1')
+
+    def test_rename_collision_on_one_child_does_not_block_others(self):
+        # Each child template is renamed independently: a collision on one must not prevent another,
+        # non-colliding subinterface template in the same batch from being renamed.
+        colliding_child = InterfaceTemplate.objects.create(
+            device_type=self.device_type, name='et0:1', type=InterfaceTypeChoices.TYPE_CHANNEL,
+            parent=self.parent, channel_id=1
+        )
+        clear_child = InterfaceTemplate.objects.create(
+            device_type=self.device_type, name='et0:2', type=InterfaceTypeChoices.TYPE_CHANNEL,
+            parent=self.parent, channel_id=2
+        )
+        InterfaceTemplate.objects.create(
+            device_type=self.device_type, name='et1:1', type=InterfaceTypeChoices.TYPE_VIRTUAL
+        )
+
+        self.parent.name = 'et1'
+        with self.captureOnCommitCallbacks(execute=True):
+            self.parent.save()
+
+        colliding_child.refresh_from_db()
+        clear_child.refresh_from_db()
+        self.assertEqual(colliding_child.name, 'et0:1')
+        self.assertEqual(clear_child.name, 'et1:2')
+
+    def test_rename_cascade_is_deferred_until_transaction_commits(self):
+        # See the identical test on Interface: the cascade must not run until the enclosing transaction commits,
+        # so a sibling template saved later in the same transaction cannot silently undo it.
+        child = InterfaceTemplate.objects.create(
+            device_type=self.device_type, name='et0:1', type=InterfaceTypeChoices.TYPE_CHANNEL,
+            parent=self.parent, channel_id=1
+        )
+
+        with self.captureOnCommitCallbacks(execute=False) as callbacks:
+            self.parent.name = 'et1'
+            self.parent.save()
+
+            child.refresh_from_db()
+            self.assertEqual(child.name, 'et0:1')
+
+            stale_copy = InterfaceTemplate.objects.get(pk=child.pk)
+            stale_copy.save()
+
+        for callback in callbacks:
+            callback()
+        child.refresh_from_db()
+        self.assertEqual(child.name, 'et1:1')
 
 
 class ChannelizedBulkCreateTestCase(ViewTestCase):