ソースを参照

Closes #20972: Add support for channelized subinterfaces (#22647)

Jeremy Stretch 1 ヶ月 前
コミット
d5dca3ae81

+ 15 - 2
docs/models/dcim/interface.md

@@ -28,11 +28,17 @@ An alternative physical label identifying the interface.
 
 
 ### Type
 ### Type
 
 
-The type of interface. Interfaces may be physical or virtual in nature, but only physical interfaces may be connected via cables.
+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.
 
 
 !!! note
 !!! 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.
     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).
+
+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.
+
 ### Speed
 ### Speed
 
 
 The operating speed, in kilobits per second (kbps).
 The operating speed, in kilobits per second (kbps).
@@ -78,11 +84,18 @@ If selected, this component will be treated as if a cable has been connected.
 
 
 ### Parent Interface
 ### 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.
+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.
 
 
 !!! note
 !!! 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.
 
 
+### 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.
+
+!!! 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.
+
 ### Bridged Interface
 ### Bridged Interface
 
 
 Interfaces can be bridged to other interfaces on a device in two manners: symmetric or grouped.
 Interfaces can be bridged to other interfaces on a device in two manners: symmetric or grouped.

+ 2 - 1
netbox/dcim/api/serializers_/device_components.py

@@ -263,7 +263,8 @@ class InterfaceSerializer(
     class Meta:
     class Meta:
         model = Interface
         model = Interface
         fields = [
         fields = [
-            'id', 'url', 'display_url', 'display', 'device', 'vdcs', 'module', 'name', 'label', 'type', 'enabled',
+            'id', 'url', 'display_url', 'display', 'device', 'vdcs', 'module', 'name', 'label', 'type', 'channels',
+            'channel_id', 'enabled',
             'parent', 'bridge', 'bridge_interfaces', 'lag', 'mtu', 'mac_address', 'primary_mac_address',
             'parent', 'bridge', 'bridge_interfaces', 'lag', 'mtu', 'mac_address', 'primary_mac_address',
             'mac_addresses', 'speed', 'duplex', 'wwn', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel',
             'mac_addresses', 'speed', 'duplex', 'wwn', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel',
             'poe_mode', 'poe_type', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan',
             'poe_mode', 'poe_type', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan',

+ 7 - 2
netbox/dcim/api/serializers_/devicetype_components.py

@@ -184,6 +184,10 @@ class InterfaceTemplateSerializer(ComponentTemplateSerializer):
         default=None
         default=None
     )
     )
     type = ChoiceField(choices=InterfaceTypeChoices)
     type = ChoiceField(choices=InterfaceTypeChoices)
+    parent = NestedInterfaceTemplateSerializer(
+        required=False,
+        allow_null=True
+    )
     bridge = NestedInterfaceTemplateSerializer(
     bridge = NestedInterfaceTemplateSerializer(
         required=False,
         required=False,
         allow_null=True
         allow_null=True
@@ -210,8 +214,9 @@ class InterfaceTemplateSerializer(ComponentTemplateSerializer):
     class Meta:
     class Meta:
         model = InterfaceTemplate
         model = InterfaceTemplate
         fields = [
         fields = [
-            'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'enabled',
-            'mgmt_only', 'description', 'bridge', 'poe_mode', 'poe_type', 'rf_role', 'created', 'last_updated',
+            'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'channels', 'channel_id',
+            'enabled', 'mgmt_only', 'description', 'parent', 'bridge', 'poe_mode', 'poe_type', 'rf_role', 'created',
+            'last_updated',
         ]
         ]
         brief_fields = ('id', 'url', 'display', 'name', 'description')
         brief_fields = ('id', 'url', 'display', 'name', 'description')
 
 

+ 2 - 0
netbox/dcim/choices.py

@@ -914,6 +914,7 @@ class InterfaceTypeChoices(ChoiceSet):
     TYPE_VIRTUAL = 'virtual'
     TYPE_VIRTUAL = 'virtual'
     TYPE_BRIDGE = 'bridge'
     TYPE_BRIDGE = 'bridge'
     TYPE_LAG = 'lag'
     TYPE_LAG = 'lag'
+    TYPE_CHANNEL = 'channel'
 
 
     # FastEthernet
     # FastEthernet
     TYPE_100ME_FX = '100base-fx'
     TYPE_100ME_FX = '100base-fx'
@@ -1185,6 +1186,7 @@ class InterfaceTypeChoices(ChoiceSet):
                 Choice(TYPE_VIRTUAL, _('Virtual')),
                 Choice(TYPE_VIRTUAL, _('Virtual')),
                 Choice(TYPE_BRIDGE, _('Bridge')),
                 Choice(TYPE_BRIDGE, _('Bridge')),
                 Choice(TYPE_LAG, _('Link Aggregation Group (LAG)')),
                 Choice(TYPE_LAG, _('Link Aggregation Group (LAG)')),
+                Choice(TYPE_CHANNEL, _('Channel')),
             ),
             ),
         ),
         ),
         (
         (

+ 10 - 1
netbox/dcim/constants.py

@@ -48,6 +48,12 @@ PORT_POSITION_MAX = 1024
 INTERFACE_MTU_MIN = 1
 INTERFACE_MTU_MIN = 1
 INTERFACE_MTU_MAX = 65536
 INTERFACE_MTU_MAX = 65536
 
 
+# The number of channels on a channelized interface, and the channel to which a subinterface is bound. A subinterface's
+# channel_id maps directly to a position on the parent interface's cable connector, so these are bounded by the maximum
+# cable position.
+INTERFACE_CHANNELS_MIN = CABLE_POSITION_MIN
+INTERFACE_CHANNELS_MAX = CABLE_POSITION_MAX
+
 VIRTUAL_IFACE_TYPES = [
 VIRTUAL_IFACE_TYPES = [
     InterfaceTypeChoices.TYPE_VIRTUAL,
     InterfaceTypeChoices.TYPE_VIRTUAL,
     InterfaceTypeChoices.TYPE_LAG,
     InterfaceTypeChoices.TYPE_LAG,
@@ -73,7 +79,10 @@ WIRELESS_IFACE_TYPES = [
     InterfaceTypeChoices.TYPE_5G,
     InterfaceTypeChoices.TYPE_5G,
 ]
 ]
 
 
-NONCONNECTABLE_IFACE_TYPES = VIRTUAL_IFACE_TYPES + WIRELESS_IFACE_TYPES
+NONCONNECTABLE_IFACE_TYPES = VIRTUAL_IFACE_TYPES + WIRELESS_IFACE_TYPES + [
+    # Channel subinterfaces derive their cable from the (channelized) parent interface and cannot be cabled directly
+    InterfaceTypeChoices.TYPE_CHANNEL,
+]
 
 
 
 
 #
 #

+ 9 - 4
netbox/dcim/filtersets.py

@@ -1058,6 +1058,11 @@ class InterfaceTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeCo
         distinct=False,
         distinct=False,
         null_value=None
         null_value=None
     )
     )
+    parent_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='parent',
+        queryset=InterfaceTemplate.objects.all(),
+        distinct=False,
+    )
     bridge_id = django_filters.ModelMultipleChoiceFilter(
     bridge_id = django_filters.ModelMultipleChoiceFilter(
         field_name='bridge',
         field_name='bridge',
         queryset=InterfaceTemplate.objects.all(),
         queryset=InterfaceTemplate.objects.all(),
@@ -1078,7 +1083,7 @@ class InterfaceTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeCo
 
 
     class Meta:
     class Meta:
         model = InterfaceTemplate
         model = InterfaceTemplate
-        fields = ('id', 'name', 'label', 'type', 'enabled', 'mgmt_only', 'description')
+        fields = ('id', 'name', 'label', 'type', 'channels', 'channel_id', 'enabled', 'mgmt_only', 'description')
 
 
 
 
 @register_filterset
 @register_filterset
@@ -2416,9 +2421,9 @@ class InterfaceFilterSet(
     class Meta:
     class Meta:
         model = Interface
         model = Interface
         fields = (
         fields = (
-            'id', 'name', 'label', 'type', 'enabled', 'mtu', 'mgmt_only', 'poe_mode', 'poe_type', 'mode', 'rf_role',
-            'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'mark_connected',
-            'cable_id', 'cable_end', 'cable_connector',
+            'id', 'name', 'label', 'type', 'channels', 'channel_id', 'enabled', 'mtu', 'mgmt_only', 'poe_mode',
+            'poe_type', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power',
+            'description', 'mark_connected', 'cable_id', 'cable_end', 'cable_connector',
         )
         )
 
 
     def filter_virtual_chassis_member_or_master(self, queryset, name, value):
     def filter_virtual_chassis_member_or_master(self, queryset, name, value):

+ 17 - 9
netbox/dcim/forms/bulk_edit.py

@@ -1191,6 +1191,14 @@ class InterfaceTemplateBulkEditForm(ComponentTemplateBulkEditForm):
         choices=add_blank_choice(InterfaceTypeChoices),
         choices=add_blank_choice(InterfaceTypeChoices),
         required=False
         required=False
     )
     )
+    channels = forms.IntegerField(
+        label=_('Channels'),
+        required=False
+    )
+    channel_id = forms.IntegerField(
+        label=_('Channel ID'),
+        required=False
+    )
     enabled = forms.NullBooleanField(
     enabled = forms.NullBooleanField(
         label=_('Enabled'),
         label=_('Enabled'),
         required=False,
         required=False,
@@ -1224,7 +1232,7 @@ class InterfaceTemplateBulkEditForm(ComponentTemplateBulkEditForm):
         label=_('Wireless role')
         label=_('Wireless role')
     )
     )
 
 
-    nullable_fields = ('label', 'description', 'poe_mode', 'poe_type', 'rf_role')
+    nullable_fields = ('label', 'channels', 'channel_id', 'description', 'poe_mode', 'poe_type', 'rf_role')
 
 
 
 
 class FrontPortTemplateBulkEditForm(ComponentTemplateBulkEditForm):
 class FrontPortTemplateBulkEditForm(ComponentTemplateBulkEditForm):
@@ -1477,9 +1485,9 @@ class PowerOutletBulkEditForm(
 class InterfaceBulkEditForm(
 class InterfaceBulkEditForm(
     ComponentBulkEditForm,
     ComponentBulkEditForm,
     form_from_model(Interface, [
     form_from_model(Interface, [
-        'label', 'type', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'wwn', 'mtu', 'mgmt_only', 'mark_connected',
-        'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power',
-        'wireless_lans', 'vlan_translation_policy'
+        'label', 'type', 'channels', 'channel_id', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'wwn', 'mtu',
+        'mgmt_only', 'mark_connected', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency',
+        'rf_channel_width', 'tx_power', 'wireless_lans', 'vlan_translation_policy'
     ])
     ])
 ):
 ):
     enabled = forms.NullBooleanField(
     enabled = forms.NullBooleanField(
@@ -1627,11 +1635,11 @@ class InterfaceBulkEditForm(
 
 
     model = Interface
     model = Interface
     fieldsets = (
     fieldsets = (
-        FieldSet('module', 'type', 'label', 'speed', 'duplex', 'description'),
+        FieldSet('module', 'type', 'channels', 'label', 'speed', 'duplex', 'description'),
         FieldSet('vrf', 'wwn', name=_('Addressing')),
         FieldSet('vrf', 'wwn', name=_('Addressing')),
         FieldSet('vdcs', 'mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected', name=_('Operation')),
         FieldSet('vdcs', 'mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected', name=_('Operation')),
         FieldSet('poe_mode', 'poe_type', name=_('PoE')),
         FieldSet('poe_mode', 'poe_type', name=_('PoE')),
-        FieldSet('parent', 'bridge', 'lag', name=_('Related Interfaces')),
+        FieldSet('parent', 'channel_id', 'bridge', 'lag', name=_('Related Interfaces')),
         FieldSet(
         FieldSet(
             'mode', 'vlan_group', 'untagged_vlan', 'qinq_svlan', 'vlan_translation_policy', name=_('802.1Q Switching')
             'mode', 'vlan_group', 'untagged_vlan', 'qinq_svlan', 'vlan_translation_policy', name=_('802.1Q Switching')
         ),
         ),
@@ -1647,9 +1655,9 @@ class InterfaceBulkEditForm(
         ),
         ),
     )
     )
     nullable_fields = (
     nullable_fields = (
-        'module', 'label', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'wwn', 'vdcs', 'mtu', 'description',
-        'poe_mode', 'poe_type', 'mode', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power',
-        'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vrf', 'wireless_lans', 'vlan_translation_policy',
+        'module', 'label', 'channels', 'channel_id', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'wwn', 'vdcs',
+        'mtu', 'description', 'poe_mode', 'poe_type', 'mode', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width',
+        'tx_power', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vrf', 'wireless_lans', 'vlan_translation_policy',
     )
     )
 
 
     def __init__(self, *args, **kwargs):
     def __init__(self, *args, **kwargs):

+ 3 - 3
netbox/dcim/forms/bulk_import.py

@@ -1080,9 +1080,9 @@ class InterfaceImportForm(OwnerCSVMixin, NetBoxModelImportForm):
     class Meta:
     class Meta:
         model = Interface
         model = Interface
         fields = (
         fields = (
-            'device', 'name', 'label', 'parent', 'bridge', 'lag', 'type', 'speed', 'duplex', 'enabled',
-            'mark_connected', 'wwn', 'vdcs', 'mtu', 'mgmt_only', 'description', 'poe_mode', 'poe_type', 'mode',
-            'vlan_group', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vrf', 'rf_role', 'rf_channel',
+            'device', 'name', 'label', 'parent', 'bridge', 'lag', 'type', 'channels', 'channel_id', 'speed', 'duplex',
+            'enabled', 'mark_connected', 'wwn', 'vdcs', 'mtu', 'mgmt_only', 'description', 'poe_mode', 'poe_type',
+            'mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vrf', 'rf_role', 'rf_channel',
             'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'owner', 'tags'
             'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'owner', 'tags'
         )
         )
 
 

+ 12 - 1
netbox/dcim/forms/filtersets.py

@@ -1687,7 +1687,10 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
     model = Interface
     model = Interface
     fieldsets = (
     fieldsets = (
         FieldSet('q', 'filter_id', 'tag'),
         FieldSet('q', 'filter_id', 'tag'),
-        FieldSet('name', 'label', 'kind', 'type', 'speed', 'duplex', 'enabled', 'mgmt_only', name=_('Attributes')),
+        FieldSet(
+            'name', 'label', 'kind', 'type', 'channels', 'channel_id', 'speed', 'duplex', 'enabled', 'mgmt_only',
+            name=_('Attributes')
+        ),
         FieldSet('vrf_id', 'l2vpn_id', 'mac_address', 'wwn', name=_('Addressing')),
         FieldSet('vrf_id', 'l2vpn_id', 'mac_address', 'wwn', name=_('Addressing')),
         FieldSet('poe_mode', 'poe_type', name=_('PoE')),
         FieldSet('poe_mode', 'poe_type', name=_('PoE')),
         FieldSet('mode', 'vlan_translation_policy_id', name=_('802.1Q Switching')),
         FieldSet('mode', 'vlan_translation_policy_id', name=_('802.1Q Switching')),
@@ -1720,6 +1723,14 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
         choices=InterfaceTypeChoices,
         choices=InterfaceTypeChoices,
         required=False
         required=False
     )
     )
+    channels = forms.IntegerField(
+        label=_('Channels'),
+        required=False
+    )
+    channel_id = forms.IntegerField(
+        label=_('Channel ID'),
+        required=False
+    )
     speed = PositiveBigIntegerField(
     speed = PositiveBigIntegerField(
         label=_('Speed'),
         label=_('Speed'),
         required=False,
         required=False,

+ 21 - 10
netbox/dcim/forms/model_forms.py

@@ -1391,6 +1391,15 @@ class InterfaceTemplateForm(ModularComponentTemplateForm):
         choices=add_blank_choice(WirelessRoleChoices),
         choices=add_blank_choice(WirelessRoleChoices),
         required=False,
         required=False,
     )
     )
+    parent = DynamicModelChoiceField(
+        label=_('Parent'),
+        queryset=InterfaceTemplate.objects.all(),
+        required=False,
+        query_params={
+            'device_type_id': '$device_type',
+            'module_type_id': '$module_type',
+        }
+    )
     bridge = DynamicModelChoiceField(
     bridge = DynamicModelChoiceField(
         label=_('Bridge'),
         label=_('Bridge'),
         queryset=InterfaceTemplate.objects.all(),
         queryset=InterfaceTemplate.objects.all(),
@@ -1407,7 +1416,8 @@ class InterfaceTemplateForm(ModularComponentTemplateForm):
                 FieldSet('device_type', name=_('Device Type')),
                 FieldSet('device_type', name=_('Device Type')),
                 FieldSet('module_type', name=_('Module Type')),
                 FieldSet('module_type', name=_('Module Type')),
             ),
             ),
-            'name', 'label', 'type', 'enabled', 'mgmt_only', 'description', 'bridge',
+            'name', 'label', 'type', 'channels', 'channel_id', 'enabled', 'mgmt_only', 'description', 'parent',
+            'bridge',
         ),
         ),
         FieldSet('poe_mode', 'poe_type', name=_('PoE')),
         FieldSet('poe_mode', 'poe_type', name=_('PoE')),
         FieldSet('rf_role', name=_('Wireless')),
         FieldSet('rf_role', name=_('Wireless')),
@@ -1416,8 +1426,8 @@ class InterfaceTemplateForm(ModularComponentTemplateForm):
     class Meta:
     class Meta:
         model = InterfaceTemplate
         model = InterfaceTemplate
         fields = [
         fields = [
-            'device_type', 'module_type', 'name', 'label', 'type', 'mgmt_only', 'enabled', 'description', 'poe_mode',
-            'poe_type', 'bridge', 'rf_role',
+            'device_type', 'module_type', 'name', 'label', 'type', 'channels', 'channel_id', 'mgmt_only', 'enabled',
+            'description', 'poe_mode', 'poe_type', 'parent', 'bridge', 'rf_role',
         ]
         ]
 
 
 
 
@@ -1939,11 +1949,12 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
 
 
     fieldsets = (
     fieldsets = (
         FieldSet(
         FieldSet(
-            'device', 'module', 'name', 'label', 'type', 'speed', 'duplex', 'description', 'tags', name=_('Interface')
+            'device', 'module', 'name', 'label', 'type', 'channels', 'speed', 'duplex', 'description', 'tags',
+            name=_('Interface')
         ),
         ),
         FieldSet('vrf', 'mac_address', 'wwn', name=_('Addressing')),
         FieldSet('vrf', 'mac_address', 'wwn', name=_('Addressing')),
         FieldSet('vdcs', 'mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected', name=_('Operation')),
         FieldSet('vdcs', 'mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected', name=_('Operation')),
-        FieldSet('parent', 'bridge', 'lag', name=_('Related Interfaces')),
+        FieldSet('parent', 'channel_id', 'bridge', 'lag', name=_('Related Interfaces')),
         FieldSet('poe_mode', 'poe_type', name=_('PoE')),
         FieldSet('poe_mode', 'poe_type', name=_('PoE')),
         FieldSet(
         FieldSet(
             'mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vlan_translation_policy',
             'mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vlan_translation_policy',
@@ -1959,11 +1970,11 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
     class Meta:
     class Meta:
         model = Interface
         model = Interface
         fields = [
         fields = [
-            'device', 'module', 'vdcs', 'name', 'label', 'type', 'speed', 'duplex', 'enabled', 'parent', 'bridge',
-            'lag', 'wwn', 'mtu', 'mgmt_only', 'mark_connected', 'description', 'poe_mode', 'poe_type', 'mode',
-            'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'wireless_lans',
-            'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vlan_translation_policy', 'vrf',
-            'owner', 'tags',
+            'device', 'module', 'vdcs', 'name', 'label', 'type', 'channels', 'channel_id', 'speed', 'duplex',
+            'enabled', 'parent', 'bridge', 'lag', 'wwn', 'mtu', 'mgmt_only', 'mark_connected', 'description',
+            'poe_mode', 'poe_type', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width',
+            'tx_power', 'wireless_lans', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vlan_translation_policy',
+            'vrf', 'owner', 'tags',
         ]
         ]
         widgets = {
         widgets = {
             'speed': NumberWithOptions(
             'speed': NumberWithOptions(

+ 18 - 3
netbox/dcim/forms/object_create.py

@@ -4,7 +4,12 @@ from django.utils.translation import gettext_lazy as _
 from dcim.models import *
 from dcim.models import *
 from netbox.forms import NetBoxModelForm
 from netbox.forms import NetBoxModelForm
 from netbox.forms.mixins import OwnerMixin
 from netbox.forms.mixins import OwnerMixin
-from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField
+from utilities.forms.fields import (
+    DynamicModelChoiceField,
+    DynamicModelMultipleChoiceField,
+    ExpandableNameField,
+    ExpandableNumericField,
+)
 from utilities.forms.rendering import FieldSet, TabbedGroups
 from utilities.forms.rendering import FieldSet, TabbedGroups
 from utilities.forms.widgets import APISelect
 from utilities.forms.widgets import APISelect
 
 
@@ -105,9 +110,14 @@ class PowerOutletTemplateCreateForm(ComponentCreateForm, model_forms.PowerOutlet
 
 
 
 
 class InterfaceTemplateCreateForm(ComponentCreateForm, model_forms.InterfaceTemplateForm):
 class InterfaceTemplateCreateForm(ComponentCreateForm, model_forms.InterfaceTemplateForm):
+    channel_id = ExpandableNumericField(
+        label=_('Channel ID'),
+        required=False
+    )
+    replication_fields = ('name', 'label', 'channel_id')
 
 
     class Meta(model_forms.InterfaceTemplateForm.Meta):
     class Meta(model_forms.InterfaceTemplateForm.Meta):
-        exclude = ('name', 'label')
+        exclude = ('name', 'label', 'channel_id')
 
 
 
 
 class FrontPortTemplateCreateForm(ComponentCreateForm, model_forms.FrontPortTemplateForm):
 class FrontPortTemplateCreateForm(ComponentCreateForm, model_forms.FrontPortTemplateForm):
@@ -197,9 +207,14 @@ class PowerOutletCreateForm(ComponentCreateForm, model_forms.PowerOutletForm):
 
 
 
 
 class InterfaceCreateForm(ComponentCreateForm, model_forms.InterfaceForm):
 class InterfaceCreateForm(ComponentCreateForm, model_forms.InterfaceForm):
+    channel_id = ExpandableNumericField(
+        label=_('Channel ID'),
+        required=False
+    )
+    replication_fields = ('name', 'label', 'channel_id')
 
 
     class Meta(model_forms.InterfaceForm.Meta):
     class Meta(model_forms.InterfaceForm.Meta):
-        exclude = ('name', 'label')
+        exclude = ('name', 'label', 'channel_id')
 
 
 
 
 class FrontPortCreateForm(ComponentCreateForm, model_forms.FrontPortForm):
 class FrontPortCreateForm(ComponentCreateForm, model_forms.FrontPortForm):

+ 2 - 2
netbox/dcim/forms/object_import.py

@@ -104,8 +104,8 @@ class InterfaceTemplateImportForm(forms.ModelForm):
     class Meta:
     class Meta:
         model = InterfaceTemplate
         model = InterfaceTemplate
         fields = [
         fields = [
-            'device_type', 'module_type', 'name', 'label', 'type', 'enabled', 'mgmt_only', 'description', 'poe_mode',
-            'poe_type', 'rf_role'
+            'device_type', 'module_type', 'name', 'label', 'type', 'channels', 'channel_id', 'enabled', 'mgmt_only',
+            'description', 'poe_mode', 'poe_type', 'rf_role'
         ]
         ]
 
 
 
 

+ 16 - 0
netbox/dcim/graphql/filters.py

@@ -540,6 +540,12 @@ class InterfaceFilter(
     type: BaseFilterLookup[Annotated['InterfaceTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
     type: BaseFilterLookup[Annotated['InterfaceTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
+    channels: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+        strawberry_django.filter_field()
+    )
+    channel_id: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+        strawberry_django.filter_field()
+    )
     mgmt_only: FilterLookup[bool] | None = strawberry_django.filter_field()
     mgmt_only: FilterLookup[bool] | None = strawberry_django.filter_field()
     speed: Annotated['BigIntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
     speed: Annotated['BigIntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
@@ -629,8 +635,18 @@ class InterfaceTemplateFilter(ModularComponentTemplateFilterMixin, ChangeLoggedM
     type: BaseFilterLookup[Annotated['InterfaceTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
     type: BaseFilterLookup[Annotated['InterfaceTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )
+    channels: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+        strawberry_django.filter_field()
+    )
+    channel_id: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+        strawberry_django.filter_field()
+    )
     enabled: FilterLookup[bool] | None = strawberry_django.filter_field()
     enabled: FilterLookup[bool] | None = strawberry_django.filter_field()
     mgmt_only: FilterLookup[bool] | None = strawberry_django.filter_field()
     mgmt_only: FilterLookup[bool] | None = strawberry_django.filter_field()
+    parent: Annotated['InterfaceTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+        strawberry_django.filter_field()
+    )
+    parent_id: ID | None = strawberry_django.filter_field()
     bridge: Annotated['InterfaceTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
     bridge: Annotated['InterfaceTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
         strawberry_django.filter_field()
         strawberry_django.filter_field()
     )
     )

+ 2 - 0
netbox/dcim/graphql/types.py

@@ -482,9 +482,11 @@ class InterfaceType(IPAddressesMixin, ModularComponentType, CabledObjectMixin, P
 )
 )
 class InterfaceTemplateType(ModularComponentTemplateType):
 class InterfaceTemplateType(ModularComponentTemplateType):
     _name: str
     _name: str
+    parent: Annotated["InterfaceTemplateType", strawberry.lazy('dcim.graphql.types')] | None
     bridge: Annotated["InterfaceTemplateType", strawberry.lazy('dcim.graphql.types')] | None
     bridge: Annotated["InterfaceTemplateType", strawberry.lazy('dcim.graphql.types')] | None
 
 
     bridge_interfaces: list[Annotated["InterfaceTemplateType", strawberry.lazy('dcim.graphql.types')]]
     bridge_interfaces: list[Annotated["InterfaceTemplateType", strawberry.lazy('dcim.graphql.types')]]
+    child_interfaces: list[Annotated["InterfaceTemplateType", strawberry.lazy('dcim.graphql.types')]]
 
 
 
 
 @strawberry_django.type(
 @strawberry_django.type(

+ 54 - 0
netbox/dcim/migrations/0246_interfacetemplate_channels.py

@@ -0,0 +1,54 @@
+import django.core.validators
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0245_modulebaytype'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='interfacetemplate',
+            name='parent',
+            field=models.ForeignKey(
+                blank=True,
+                null=True,
+                on_delete=django.db.models.deletion.RESTRICT,
+                related_name='child_interfaces',
+                to='dcim.interfacetemplate',
+            ),
+        ),
+        migrations.AddField(
+            model_name='interfacetemplate',
+            name='channel_id',
+            field=models.PositiveSmallIntegerField(
+                blank=True,
+                null=True,
+                validators=[
+                    django.core.validators.MinValueValidator(1),
+                    django.core.validators.MaxValueValidator(1024),
+                ],
+            ),
+        ),
+        migrations.AddField(
+            model_name='interfacetemplate',
+            name='channels',
+            field=models.PositiveSmallIntegerField(
+                blank=True,
+                null=True,
+                validators=[
+                    django.core.validators.MinValueValidator(1),
+                    django.core.validators.MaxValueValidator(1024),
+                ],
+            ),
+        ),
+        migrations.AddConstraint(
+            model_name='interfacetemplate',
+            constraint=models.UniqueConstraint(
+                fields=('parent', 'channel_id'),
+                name='dcim_interfacetemplate_unique_parent_channel_id',
+            ),
+        ),
+    ]

+ 43 - 0
netbox/dcim/migrations/0247_interface_channels.py

@@ -0,0 +1,43 @@
+import django.core.validators
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0246_interfacetemplate_channels'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='interface',
+            name='channel_id',
+            field=models.PositiveSmallIntegerField(
+                blank=True,
+                null=True,
+                validators=[
+                    django.core.validators.MinValueValidator(1),
+                    django.core.validators.MaxValueValidator(1024),
+                ],
+            ),
+        ),
+        migrations.AddField(
+            model_name='interface',
+            name='channels',
+            field=models.PositiveSmallIntegerField(
+                blank=True,
+                null=True,
+                validators=[
+                    django.core.validators.MinValueValidator(1),
+                    django.core.validators.MaxValueValidator(1024),
+                ],
+            ),
+        ),
+        migrations.AddConstraint(
+            model_name='interface',
+            constraint=models.UniqueConstraint(
+                fields=('parent', 'channel_id'),
+                name='dcim_interface_unique_parent_channel_id',
+            ),
+        ),
+    ]

+ 14 - 0
netbox/dcim/models/cables.py

@@ -636,6 +636,14 @@ class CableTermination(ChangeLoggedModel):
                     cable_pk=existing_termination.cable.pk
                     cable_pk=existing_termination.cable.pk
                 )
                 )
             )
             )
+        # A channel subinterface derives its cable from its parent interface and cannot be cabled directly. Checked
+        # ahead of the generic type validation below (channel is a nonconnectable type) to surface the more specific
+        # guidance.
+        if self.termination_type.model == 'interface' and self.termination.channel_id:
+            raise ValidationError(
+                _("Cables cannot be terminated directly to a channel subinterface; cable the parent interface instead.")
+            )
+
         # Validate the interface type (if applicable)
         # Validate the interface type (if applicable)
         if self.termination_type.model == 'interface' and self.termination.type in NONCONNECTABLE_IFACE_TYPES:
         if self.termination_type.model == 'interface' and self.termination.type in NONCONNECTABLE_IFACE_TYPES:
             raise ValidationError(
             raise ValidationError(
@@ -971,6 +979,12 @@ class CablePath(models.Model):
                     peer_results = cable_profile.get_peer_terminations(term_position_pairs)
                     peer_results = cable_profile.get_peer_terminations(term_position_pairs)
                     seen = set()
                     seen = set()
                     for peer, new_pos in peer_results:
                     for peer, new_pos in peer_results:
+                        # If the far-end termination is a channelized interface, resolve to the specific channel
+                        # subinterface bound to the mapped connector position (the far end is channelized on the same
+                        # physical connector, so the peer lookup returns the parent rather than the channel). A
+                        # channelized parent is never itself a path endpoint, so an unoccupied position yields no peer.
+                        if new_pos is not None and getattr(peer, 'channels', None):
+                            peer = peer.child_interfaces.filter(channel_id=new_pos).first()
                         # Deduplicate peer terminations by model type & PK.
                         # Deduplicate peer terminations by model type & PK.
                         key = None if peer is None else (peer._meta.concrete_model, peer.pk)
                         key = None if peer is None else (peer._meta.concrete_model, peer.pk)
                         if key not in seen:
                         if key not in seen:

+ 62 - 0
netbox/dcim/models/device_component_templates.py

@@ -454,6 +454,26 @@ class InterfaceTemplate(InterfaceValidationMixin, ModularComponentTemplateModel)
         max_length=50,
         max_length=50,
         choices=InterfaceTypeChoices
         choices=InterfaceTypeChoices
     )
     )
+    channels = models.PositiveSmallIntegerField(
+        verbose_name=_('channels'),
+        blank=True,
+        null=True,
+        validators=(
+            MinValueValidator(INTERFACE_CHANNELS_MIN),
+            MaxValueValidator(INTERFACE_CHANNELS_MAX)
+        ),
+        help_text=_('The number of channels into which this interface is channelized')
+    )
+    channel_id = models.PositiveSmallIntegerField(
+        verbose_name=_('channel ID'),
+        blank=True,
+        null=True,
+        validators=(
+            MinValueValidator(INTERFACE_CHANNELS_MIN),
+            MaxValueValidator(INTERFACE_CHANNELS_MAX)
+        ),
+        help_text=_('The channel on the parent interface to which this subinterface is bound')
+    )
     enabled = models.BooleanField(
     enabled = models.BooleanField(
         verbose_name=_('enabled'),
         verbose_name=_('enabled'),
         default=True
         default=True
@@ -462,6 +482,14 @@ class InterfaceTemplate(InterfaceValidationMixin, ModularComponentTemplateModel)
         default=False,
         default=False,
         verbose_name=_('management only')
         verbose_name=_('management only')
     )
     )
+    parent = models.ForeignKey(
+        to='self',
+        on_delete=models.RESTRICT,
+        related_name='child_interfaces',
+        null=True,
+        blank=True,
+        verbose_name=_('parent interface')
+    )
     bridge = models.ForeignKey(
     bridge = models.ForeignKey(
         to='self',
         to='self',
         on_delete=models.SET_NULL,
         on_delete=models.SET_NULL,
@@ -495,12 +523,41 @@ class InterfaceTemplate(InterfaceValidationMixin, ModularComponentTemplateModel)
     component_model = Interface
     component_model = Interface
 
 
     class Meta(ModularComponentTemplateModel.Meta):
     class Meta(ModularComponentTemplateModel.Meta):
+        constraints = (
+            *ModularComponentTemplateModel.Meta.constraints,
+            models.UniqueConstraint(
+                fields=('parent', 'channel_id'),
+                name='%(app_label)s_%(class)s_unique_parent_channel_id'
+            ),
+        )
         verbose_name = _('interface template')
         verbose_name = _('interface template')
         verbose_name_plural = _('interface templates')
         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):
     def clean(self):
         super().clean()
         super().clean()
 
 
+        # Self-reference and interface-type restrictions are enforced by InterfaceValidationMixin
+        if self.parent:
+            if self.device_type and self.device_type != self.parent.device_type:
+                raise ValidationError({
+                    'parent': _(
+                        "Parent interface ({parent}) must belong to the same device type"
+                    ).format(parent=self.parent)
+                })
+            if self.module_type and self.module_type != self.parent.module_type:
+                raise ValidationError({
+                    'parent': _(
+                        "Parent interface ({parent}) must belong to the same module type"
+                    ).format(parent=self.parent)
+                })
+
         if self.bridge:
         if self.bridge:
             if self.device_type and self.device_type != self.bridge.device_type:
             if self.device_type and self.device_type != self.bridge.device_type:
                 raise ValidationError({
                 raise ValidationError({
@@ -520,6 +577,8 @@ class InterfaceTemplate(InterfaceValidationMixin, ModularComponentTemplateModel)
             name=self.resolve_name(kwargs.get('module'), kwargs.get('device')),
             name=self.resolve_name(kwargs.get('module'), kwargs.get('device')),
             label=self.resolve_label(kwargs.get('module'), kwargs.get('device')),
             label=self.resolve_label(kwargs.get('module'), kwargs.get('device')),
             type=self.type,
             type=self.type,
+            channels=self.channels,
+            channel_id=self.channel_id,
             enabled=self.enabled,
             enabled=self.enabled,
             mgmt_only=self.mgmt_only,
             mgmt_only=self.mgmt_only,
             poe_mode=self.poe_mode,
             poe_mode=self.poe_mode,
@@ -533,10 +592,13 @@ class InterfaceTemplate(InterfaceValidationMixin, ModularComponentTemplateModel)
         return {
         return {
             'name': self.name,
             'name': self.name,
             'type': self.type,
             'type': self.type,
+            'channels': self.channels,
+            'channel_id': self.channel_id,
             'enabled': self.enabled,
             'enabled': self.enabled,
             'mgmt_only': self.mgmt_only,
             'mgmt_only': self.mgmt_only,
             'label': self.label,
             'label': self.label,
             'description': self.description,
             'description': self.description,
+            'parent': self.parent.name if self.parent else None,
             'bridge': self.bridge.name if self.bridge else None,
             'bridge': self.bridge.name if self.bridge else None,
             'poe_mode': self.poe_mode,
             'poe_mode': self.poe_mode,
             'poe_type': self.poe_type,
             'poe_type': self.poe_type,

+ 101 - 12
netbox/dcim/models/device_components.py

@@ -860,6 +860,26 @@ class Interface(
         max_length=50,
         max_length=50,
         choices=InterfaceTypeChoices
         choices=InterfaceTypeChoices
     )
     )
+    channels = models.PositiveSmallIntegerField(
+        verbose_name=_('channels'),
+        blank=True,
+        null=True,
+        validators=(
+            MinValueValidator(INTERFACE_CHANNELS_MIN),
+            MaxValueValidator(INTERFACE_CHANNELS_MAX)
+        ),
+        help_text=_('The number of channels into which this interface is channelized')
+    )
+    channel_id = models.PositiveSmallIntegerField(
+        verbose_name=_('channel ID'),
+        blank=True,
+        null=True,
+        validators=(
+            MinValueValidator(INTERFACE_CHANNELS_MIN),
+            MaxValueValidator(INTERFACE_CHANNELS_MAX)
+        ),
+        help_text=_('The channel on the parent interface to which this subinterface is bound')
+    )
     mgmt_only = models.BooleanField(
     mgmt_only = models.BooleanField(
         default=False,
         default=False,
         verbose_name=_('management only'),
         verbose_name=_('management only'),
@@ -989,14 +1009,33 @@ class Interface(
     )
     )
 
 
     clone_fields = (
     clone_fields = (
-        'device', 'module', 'parent', 'bridge', 'lag', 'type', 'mgmt_only', 'mtu', 'mode', 'speed', 'duplex', 'rf_role',
-        'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'poe_mode', 'poe_type', 'vrf',
+        'device', 'module', 'parent', 'bridge', 'lag', 'type', 'channels', 'mgmt_only', 'mtu', 'mode', 'speed',
+        'duplex', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'poe_mode',
+        'poe_type', 'vrf',
     )
     )
 
 
     class Meta(ModularComponentModel.Meta):
     class Meta(ModularComponentModel.Meta):
         ordering = ('device', CollateAsChar('_name'))
         ordering = ('device', CollateAsChar('_name'))
         verbose_name = _('interface')
         verbose_name = _('interface')
         verbose_name_plural = _('interfaces')
         verbose_name_plural = _('interfaces')
+        constraints = (
+            *ModularComponentModel.Meta.constraints,
+            models.UniqueConstraint(
+                fields=('parent', 'channel_id'),
+                name='%(app_label)s_%(class)s_unique_parent_channel_id'
+            ),
+        )
+
+    def __init__(self, *args, **kwargs):
+        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')
 
 
     def clean(self):
     def clean(self):
         super().clean()
         super().clean()
@@ -1017,15 +1056,7 @@ class Interface(
                 )
                 )
             })
             })
 
 
-        # Parent validation
-
-        # An interface cannot be its own parent
-        if self.pk and self.parent_id == self.pk:
-            raise ValidationError({'parent': _("An interface cannot be its own parent.")})
-
-        # A physical interface cannot have a parent interface
-        if self.type != InterfaceTypeChoices.TYPE_VIRTUAL and self.parent is not None:
-            raise ValidationError({'parent': _("Only virtual interfaces may be assigned to a parent interface.")})
+        # 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
         # An interface's parent must belong to the same device or virtual chassis
         if self.parent and self.parent.device != self.device:
         if self.parent and self.parent.device != self.device:
@@ -1147,7 +1178,9 @@ class Interface(
 
 
     @property
     @property
     def is_wired(self):
     def is_wired(self):
-        return not self.is_virtual and not self.is_wireless
+        # 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
 
 
     @property
     @property
     def is_virtual(self):
     def is_virtual(self):
@@ -1165,6 +1198,10 @@ class Interface(
     def is_bridge(self):
     def is_bridge(self):
         return self.type == InterfaceTypeChoices.TYPE_BRIDGE
         return self.type == InterfaceTypeChoices.TYPE_BRIDGE
 
 
+    @property
+    def is_channel(self):
+        return self.type == InterfaceTypeChoices.TYPE_CHANNEL
+
     @property
     @property
     def link(self):
     def link(self):
         return self.cable or self.wireless_link
         return self.cable or self.wireless_link
@@ -1192,6 +1229,58 @@ class Interface(
             return self.virtual_circuit_termination.peer_terminations
             return self.virtual_circuit_termination.peer_terminations
         return super().connected_endpoints
         return super().connected_endpoints
 
 
+    def set_cable_termination(self, termination):
+        super().set_cable_termination(termination)
+
+        # A channelized interface carries no path of its own; instead, its cable is mirrored onto each channel
+        # subinterface (occupying a single position of the shared connector) so that each channel traces independently.
+        if self.channels:
+            self.propagate_channel_cables()
+
+    def clear_cable_termination(self, termination):
+        super().clear_cable_termination(termination)
+
+        if self.channels:
+            self.clear_channel_cables()
+
+    def propagate_channel_cables(self):
+        """
+        Mirror this channelized interface's cable attributes onto each of its channel subinterfaces, restricting each
+        child to the single connector position identified by its channel_id. Only profiled cables map connector
+        positions to channels; a positionless (unprofiled) cable carries no per-channel path, so nothing is mirrored.
+        """
+        # Only a profiled cable defines the connector positions that channels map onto; without one, clear any
+        # previously-mirrored attributes rather than propagate an unusable cable reference.
+        if not (self.cable and self.cable.profile):
+            self.clear_channel_cables()
+            return
+
+        # Mirror via bulk_update() to issue a single UPDATE and, crucially, to bypass the post_save signal — a
+        # per-child save() would re-trigger update_channelized_cable_paths() and recurse indefinitely.
+        children = list(self.child_interfaces.filter(channel_id__isnull=False))
+        for child in children:
+            child.cable = self.cable
+            child.cable_end = self.cable_end
+            child.cable_connector = self.cable_connector
+            child.cable_positions = [child.channel_id]
+        type(self).objects.bulk_update(
+            children, ['cable', 'cable_end', 'cable_connector', 'cable_positions']
+        )
+
+    def clear_channel_cables(self):
+        """
+        Clear the mirrored cable attributes from this channelized interface's channel subinterfaces.
+        """
+        # A queryset update() clears every child in a single query and bypasses the post_save signal (see above).
+        # cable_end is cleared to '' to match the convention used elsewhere when nullifying a termination (see
+        # nullify_connected_endpoints() and update_channelized_cable_paths() in dcim.signals).
+        self.child_interfaces.filter(channel_id__isnull=False).update(
+            cable=None,
+            cable_end='',
+            cable_connector=None,
+            cable_positions=None,
+        )
+
 
 
 #
 #
 # Pass-through ports
 # Pass-through ports

+ 4 - 2
netbox/dcim/models/devices.py

@@ -19,7 +19,7 @@ from django.utils.translation import gettext_lazy as _
 from dcim.choices import *
 from dcim.choices import *
 from dcim.constants import *
 from dcim.constants import *
 from dcim.fields import MACAddressField
 from dcim.fields import MACAddressField
-from dcim.utils import create_port_mappings, update_interface_bridges
+from dcim.utils import create_port_mappings, update_interface_bridges, update_interface_parents
 from extras.models import ConfigContextModel, CustomField
 from extras.models import ConfigContextModel, CustomField
 from extras.querysets import ConfigContextModelQuerySet
 from extras.querysets import ConfigContextModelQuerySet
 from netbox.choices import ColorChoices
 from netbox.choices import ColorChoices
@@ -1070,7 +1070,9 @@ class Device(
             self._instantiate_components(self.device_type.devicebaytemplates.all())
             self._instantiate_components(self.device_type.devicebaytemplates.all())
             # Disable bulk_create to accommodate MPTT
             # Disable bulk_create to accommodate MPTT
             self._instantiate_components(self.device_type.inventoryitemtemplates.all(), bulk_create=False)
             self._instantiate_components(self.device_type.inventoryitemtemplates.all(), bulk_create=False)
-            # Interface bridges have to be set after interface instantiation
+            # Interface parents & bridges have to be set after interface instantiation. Parents are applied first so
+            # that channel subinterfaces validate against a populated parent.
+            update_interface_parents(self, self.device_type.interfacetemplates.all())
             update_interface_bridges(self, self.device_type.interfacetemplates.all())
             update_interface_bridges(self, self.device_type.interfacetemplates.all())
 
 
         # Update Site and Rack assignment for any child Devices
         # Update Site and Rack assignment for any child Devices

+ 77 - 1
netbox/dcim/models/mixins.py

@@ -4,7 +4,8 @@ from django.core.exceptions import ValidationError
 from django.db import models
 from django.db import models
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 
 
-from dcim.constants import VIRTUAL_IFACE_TYPES, WIRELESS_IFACE_TYPES
+from dcim.choices import InterfaceTypeChoices
+from dcim.constants import NONCONNECTABLE_IFACE_TYPES, VIRTUAL_IFACE_TYPES, WIRELESS_IFACE_TYPES
 
 
 __all__ = (
 __all__ = (
     'CachedScopeMixin',
     'CachedScopeMixin',
@@ -130,6 +131,81 @@ class InterfaceValidationMixin:
     def clean(self):
     def clean(self):
         super().clean()
         super().clean()
 
 
+        # An interface cannot be its own parent
+        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.")
+            })
+
+        # Only one layer of channelization is permitted: an interface cannot be both channelized and a channel
+        if self.channels and self.channel_id:
+            raise ValidationError(
+                _("An interface cannot be both channelized and bound to a channel on a parent interface.")
+            )
+
+        # Only physical interfaces may be channelized
+        if self.channels and self.type in NONCONNECTABLE_IFACE_TYPES:
+            raise ValidationError({
+                'channels': _("{display_type} interfaces cannot be channelized.").format(
+                    display_type=self.get_type_display()
+                )
+            })
+
+        # 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):
+            raise ValidationError({
+                'channel_id': _("Channel interfaces must have a channel ID assigned.")
+            })
+        if self.channel_id is not None and not is_channel:
+            raise ValidationError({
+                'channel_id': _("A channel ID can be assigned only to a channel-type interface.")
+            })
+
+        # A channel subinterface must be bound to a channelized parent interface
+        if is_channel:
+            if self.parent is None:
+                raise ValidationError({
+                    'parent': _("Channel interfaces must be assigned to a parent interface.")
+                })
+            if not self.parent.channels:
+                raise ValidationError({
+                    'parent': _("The parent interface ({interface}) is not channelized.").format(
+                        interface=self.parent
+                    )
+                })
+            if self.channel_id and self.channel_id > self.parent.channels:
+                raise ValidationError({
+                    'channel_id': _(
+                        "Invalid channel ID ({channel_id}): the parent interface provides only {channels} channels."
+                    ).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.
+        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
+            ).aggregate(models.Max('channel_id'))['channel_id__max']
+            if max_child_channel_id is not None:
+                if self.channels:
+                    message = _(
+                        "Cannot set channels to {channels}: a channel subinterface is bound to channel "
+                        "{channel_id}. Delete or reassign the affected subinterface(s) first."
+                    ).format(channels=self.channels, channel_id=max_child_channel_id)
+                else:
+                    message = _(
+                        "Cannot remove channelization: a channel subinterface is bound to channel {channel_id}. "
+                        "Delete or reassign the affected subinterface(s) first."
+                    ).format(channel_id=max_child_channel_id)
+                raise ValidationError({'channels': message})
+
         # An interface cannot be bridged to itself
         # An interface cannot be bridged to itself
         if self.pk and self.bridge_id == self.pk:
         if self.pk and self.bridge_id == self.pk:
             raise ValidationError({'bridge': _("An interface cannot be bridged to itself.")})
             raise ValidationError({'bridge': _("An interface cannot be bridged to itself.")})

+ 4 - 2
netbox/dcim/models/modules.py

@@ -9,7 +9,7 @@ from django.utils.translation import gettext_lazy as _
 from jsonschema.exceptions import ValidationError as JSONValidationError
 from jsonschema.exceptions import ValidationError as JSONValidationError
 
 
 from dcim.choices import *
 from dcim.choices import *
-from dcim.utils import create_port_mappings, update_interface_bridges
+from dcim.utils import create_port_mappings, update_interface_bridges, update_interface_parents
 from extras.models import CustomField
 from extras.models import CustomField
 from netbox.models import PrimaryModel
 from netbox.models import PrimaryModel
 from netbox.models.features import ImageAttachmentsMixin
 from netbox.models.features import ImageAttachmentsMixin
@@ -591,7 +591,9 @@ class Module(TrackingModelMixin, PrimaryModel):
         # Replicate any front/rear port mappings from the ModuleType
         # Replicate any front/rear port mappings from the ModuleType
         create_port_mappings(self.device, self.module_type, self)
         create_port_mappings(self.device, self.module_type, self)
 
 
-        # Interface bridges have to be set after interface instantiation
+        # Interface parents & bridges have to be set after interface instantiation. Parents are applied first so that
+        # channel subinterfaces validate against a populated parent.
+        update_interface_parents(self.device, self.module_type.interfacetemplates, self)
         update_interface_bridges(self.device, self.module_type.interfacetemplates, self)
         update_interface_bridges(self.device, self.module_type.interfacetemplates, self)
 
 
     def _save_existing(self, *args, **kwargs):
     def _save_existing(self, *args, **kwargs):

+ 69 - 1
netbox/dcim/signals.py

@@ -23,7 +23,7 @@ from .models import (
 )
 )
 from .models.cables import trace_paths
 from .models.cables import trace_paths
 from .search import DeviceIndex
 from .search import DeviceIndex
-from .utils import create_cablepaths, rebuild_paths
+from .utils import create_cablepaths, rebuild_cable_paths, rebuild_paths
 
 
 #
 #
 # Location/rack/device assignment
 # Location/rack/device assignment
@@ -156,6 +156,14 @@ def nullify_connected_endpoints(instance, **kwargs):
     model = instance.termination_type.model_class()
     model = instance.termination_type.model_class()
     model.objects.filter(pk=instance.termination_id).update(cable=None, cable_end='')
     model.objects.filter(pk=instance.termination_id).update(cable=None, cable_end='')
 
 
+    # If the removed termination was a channelized interface, also clear the cable attributes mirrored onto its channel
+    # subinterfaces. This must happen before the retrace below so that each channel's (now dead) path is torn down
+    # rather than rebuilt from a stale cable reference.
+    if model is Interface:
+        Interface.objects.filter(parent_id=instance.termination_id, channel_id__isnull=False).update(
+            cable=None, cable_end='', cable_connector=None, cable_positions=None
+        )
+
     # If the parent Cable is being deleted in this same operation, skip the
     # If the parent Cable is being deleted in this same operation, skip the
     # per-termination retrace; retrace_cable_paths() will retrace each affected
     # per-termination retrace; retrace_cable_paths() will retrace each affected
     # path once after the Cable is deleted.
     # path once after the Cable is deleted.
@@ -171,6 +179,66 @@ def nullify_connected_endpoints(instance, **kwargs):
         cablepath.retrace()
         cablepath.retrace()
 
 
 
 
+@receiver(post_save, sender=Interface)
+def update_channelized_cable_paths(instance, created, raw=False, **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
+    already-cabled interface. (The cable-tracing signals only fire when a Cable is saved.)
+    """
+    if raw:
+        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:
+        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)
+
+    # 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:
+        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
+        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
+            )
+            for cablepath in CablePath.objects.filter(_nodes__contains=instance):
+                if instance in cablepath.origins:
+                    cablepath.delete()
+
+    # 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
+    instance._original_channel_id = instance.channel_id
+    instance._original_parent_id = instance.parent_id
+
+
+@receiver(post_delete, sender=Interface)
+def cleanup_channel_subinterface_paths(instance, **kwargs):
+    """
+    When a channel subinterface is deleted, rebuild its channelized parent's cable paths so the removed channel's path
+    is torn down.
+    """
+    if instance.channel_id and instance.parent_id:
+        parent = Interface.objects.filter(pk=instance.parent_id, cable__isnull=False).first()
+        if parent and parent.channels:
+            parent.propagate_channel_cables()
+            rebuild_cable_paths(parent.cable)
+
+
 @receiver(post_save, sender=Interface)
 @receiver(post_save, sender=Interface)
 @receiver(post_save, sender=VMInterface)
 @receiver(post_save, sender=VMInterface)
 def update_mac_address_interface(instance, created, raw, **kwargs):
 def update_mac_address_interface(instance, created, raw, **kwargs):

+ 22 - 5
netbox/dcim/svg/cables.py

@@ -19,6 +19,22 @@ FANOUT_LEG_HEIGHT = 15
 CABLE_HEIGHT = 5 * LINE_HEIGHT + FANOUT_HEIGHT + FANOUT_LEG_HEIGHT
 CABLE_HEIGHT = 5 * LINE_HEIGHT + FANOUT_HEIGHT + FANOUT_LEG_HEIGHT
 
 
 
 
+def _cable_side_nodes(term_nodes, cable_terminations):
+    """
+    Filter a list of termination nodes to those connected to the given side of a Cable. A channel subinterface does
+    not terminate the cable directly; it derives its connection from its (channelized) parent interface, which carries
+    the actual CableTermination, so it is matched via its parent.
+    """
+    def matches(obj):
+        if obj in cable_terminations:
+            return True
+        if getattr(obj, 'channel_id', None):
+            return obj.parent in cable_terminations
+        return False
+
+    return [node for node in term_nodes if matches(node.object)]
+
+
 class Node(Hyperlink):
 class Node(Hyperlink):
     """
     """
     Create a node to be represented in the SVG document as a rectangular box with a hyperlink.
     Create a node to be represented in the SVG document as a rectangular box with a hyperlink.
@@ -380,13 +396,14 @@ class CableTraceSVG:
                             description.append(f"{cable.length} {cable.get_length_unit_display()}")
                             description.append(f"{cable.length} {cable.get_length_unit_display()}")
                         color = cable.color or '000000'
                         color = cable.color or '000000'
 
 
-                        # Collect all connected nodes to this cable
-                        near = [term for term in near_terminations if term.object in cable.a_terminations]
-                        far = [term for term in far_terminations if term.object in cable.b_terminations]
+                        # Collect all connected nodes to this cable. Channel subinterfaces are matched via their
+                        # parent interface, which carries the actual cable termination.
+                        near = _cable_side_nodes(near_terminations, cable.a_terminations)
+                        far = _cable_side_nodes(far_terminations, cable.b_terminations)
                         if not (near and far):
                         if not (near and far):
                             # a and b terminations may be swapped
                             # a and b terminations may be swapped
-                            near = [term for term in near_terminations if term.object in cable.b_terminations]
-                            far = [term for term in far_terminations if term.object in cable.a_terminations]
+                            near = _cable_side_nodes(near_terminations, cable.b_terminations)
+                            far = _cable_side_nodes(far_terminations, cable.a_terminations)
                     elif isinstance(cable, WirelessLink):
                     elif isinstance(cable, WirelessLink):
                         labels = [f"{cable}"] if len(links) > 2 else [f"Wireless {cable}", cable.get_status_display()]
                         labels = [f"{cable}"] if len(links) > 2 else [f"Wireless {cable}", cable.get_status_display()]
                         if cable.ssid:
                         if cable.ssid:

+ 6 - 6
netbox/dcim/tables/devices.py

@@ -710,12 +710,12 @@ class InterfaceTable(BaseInterfaceTable, ModularDeviceComponentTable, PathEndpoi
     class Meta(DeviceComponentTable.Meta):
     class Meta(DeviceComponentTable.Meta):
         model = models.Interface
         model = models.Interface
         fields = (
         fields = (
-            'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'enabled', 'type', 'mgmt_only', 'mtu',
-            'speed', 'speed_formatted', 'duplex', 'mode', 'mac_addresses', 'primary_mac_address', 'wwn',
-            'poe_mode', 'poe_type', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power',
-            'description', 'mark_connected', 'cable', 'cable_color', 'wireless_link', 'wireless_lans', 'link_peer',
-            'connection', 'tags', 'vdcs', 'vrf', 'l2vpn', 'tunnel', 'ip_addresses', 'fhrp_groups',
-            'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'inventory_items', 'created', 'last_updated',
+            'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'enabled', 'type', 'channels',
+            'channel_id', 'mgmt_only', 'mtu', 'speed', 'speed_formatted', 'duplex', 'mode', 'mac_addresses',
+            'primary_mac_address', 'wwn', 'poe_mode', 'poe_type', 'rf_role', 'rf_channel', 'rf_channel_frequency',
+            'rf_channel_width', 'tx_power', 'description', 'mark_connected', 'cable', 'cable_color', 'wireless_link',
+            'wireless_lans', 'link_peer', 'connection', 'tags', 'vdcs', 'vrf', 'l2vpn', 'tunnel', 'ip_addresses',
+            'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'inventory_items', 'created', 'last_updated',
             'vlan_translation_policy',
             'vlan_translation_policy',
         )
         )
         default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description')
         default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description')

+ 2 - 2
netbox/dcim/tables/devicetypes.py

@@ -244,8 +244,8 @@ class InterfaceTemplateTable(ComponentTemplateTable):
     class Meta(ComponentTemplateTable.Meta):
     class Meta(ComponentTemplateTable.Meta):
         model = models.InterfaceTemplate
         model = models.InterfaceTemplate
         fields = (
         fields = (
-            'pk', 'name', 'label', 'enabled', 'mgmt_only', 'type', 'description', 'bridge', 'poe_mode', 'poe_type',
-            'rf_role', 'actions',
+            'pk', 'name', 'label', 'enabled', 'mgmt_only', 'type', 'channels', 'channel_id', 'description', 'parent',
+            'bridge', 'poe_mode', 'poe_type', 'rf_role', 'actions',
         )
         )
         empty_text = "None"
         empty_text = "None"
 
 

+ 36 - 2
netbox/dcim/tests/test_api.py

@@ -1372,9 +1372,11 @@ class InterfaceTemplateTestCase(APIViewTestCases.APIViewTestCase):
         interface_templates = (
         interface_templates = (
             InterfaceTemplate(device_type=devicetype, name='Interface Template 1', type='1000base-t'),
             InterfaceTemplate(device_type=devicetype, name='Interface Template 1', type='1000base-t'),
             InterfaceTemplate(device_type=devicetype, name='Interface Template 2', type='1000base-t'),
             InterfaceTemplate(device_type=devicetype, name='Interface Template 2', type='1000base-t'),
-            InterfaceTemplate(device_type=devicetype, name='Interface Template 3', type='1000base-t'),
+            # Interface Template 3 is channelized, so that channel subinterface templates may be bound to it
+            InterfaceTemplate(device_type=devicetype, name='Interface Template 3', type='1000base-t', channels=4),
         )
         )
         InterfaceTemplate.objects.bulk_create(interface_templates)
         InterfaceTemplate.objects.bulk_create(interface_templates)
+        channelized_parent = interface_templates[2]
 
 
         cls.create_data = [
         cls.create_data = [
             {
             {
@@ -1397,6 +1399,21 @@ class InterfaceTemplateTestCase(APIViewTestCases.APIViewTestCase):
                 'name': 'Interface Template 7',
                 'name': 'Interface Template 7',
                 'type': '1000base-t',
                 'type': '1000base-t',
             },
             },
+            {
+                # A channelized parent template
+                'device_type': devicetype.pk,
+                'name': 'Interface Template 8',
+                'type': InterfaceTypeChoices.TYPE_40GE_QSFP_PLUS,
+                'channels': 4,
+            },
+            {
+                # A channel subinterface template bound to a channelized parent
+                'device_type': devicetype.pk,
+                'name': 'Interface Template 9',
+                'type': InterfaceTypeChoices.TYPE_CHANNEL,
+                'parent': channelized_parent.pk,
+                'channel_id': 1,
+            },
         ]
         ]
 
 
 
 
@@ -2923,9 +2940,11 @@ class InterfaceTestCase(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTest
         interfaces = (
         interfaces = (
             Interface(device=device, name='Interface 1', type='1000base-t'),
             Interface(device=device, name='Interface 1', type='1000base-t'),
             Interface(device=device, name='Interface 2', type='1000base-t'),
             Interface(device=device, name='Interface 2', type='1000base-t'),
-            Interface(device=device, name='Interface 3', type='1000base-t'),
+            # Interface 3 is channelized, so that channel subinterfaces may be bound to it
+            Interface(device=device, name='Interface 3', type='1000base-t', channels=4),
         )
         )
         Interface.objects.bulk_create(interfaces)
         Interface.objects.bulk_create(interfaces)
+        channelized_parent = interfaces[2]
 
 
         vdcs = (
         vdcs = (
             VirtualDeviceContext(name='VDC 1', identifier=1, device=device),
             VirtualDeviceContext(name='VDC 1', identifier=1, device=device),
@@ -3013,6 +3032,21 @@ class InterfaceTestCase(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTest
                 'rf_channel': "",
                 'rf_channel': "",
                 'qinq_svlan': vlans[3].pk,
                 'qinq_svlan': vlans[3].pk,
             },
             },
+            {
+                # A channelized parent interface
+                'device': device.pk,
+                'name': 'Interface 9',
+                'type': InterfaceTypeChoices.TYPE_40GE_QSFP_PLUS,
+                'channels': 4,
+            },
+            {
+                # A channel subinterface bound to a channelized parent
+                'device': device.pk,
+                'name': 'Interface 10',
+                'type': InterfaceTypeChoices.TYPE_CHANNEL,
+                'parent': channelized_parent.pk,
+                'channel_id': 1,
+            },
         ]
         ]
 
 
     def _perform_interface_test_with_invalid_data(self, mode: str = None, invalid_data: dict = {}):
     def _perform_interface_test_with_invalid_data(self, mode: str = None, invalid_data: dict = {}):

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

@@ -0,0 +1,630 @@
+from django.core.exceptions import ValidationError
+from django.test import TestCase
+from django.urls import reverse
+
+from dcim.choices import CableProfileChoices, InterfaceTypeChoices
+from dcim.models import (
+    Cable,
+    CablePath,
+    Device,
+    DeviceRole,
+    DeviceType,
+    Interface,
+    InterfaceTemplate,
+    Manufacturer,
+    Site,
+)
+from dcim.svg import CableTraceSVG
+from dcim.svg.cables import Connector
+from dcim.tests.utils import BaseCablePathTestCase
+from utilities.testing import TestCase as ViewTestCase
+
+
+class ChannelizedCablePathTestCase(BaseCablePathTestCase):
+    """
+    Test cable path tracing for channelized interfaces. A single physical cable terminates to a channelized (parent)
+    interface, and each of the parent's channel subinterfaces traces an independent path from the connector position
+    identified by its channel_id.
+    """
+
+    def _create_channelized_interface(self, name, channels, device=None):
+        """Create a channelized parent interface and its channel subinterfaces."""
+        device = device or self.device
+        parent = Interface.objects.create(
+            device=device, name=name, type=InterfaceTypeChoices.TYPE_40GE_QSFP_PLUS, channels=channels
+        )
+        children = [
+            Interface.objects.create(
+                device=device,
+                name=f'{name}:{i}',
+                type=InterfaceTypeChoices.TYPE_CHANNEL,
+                parent=parent,
+                channel_id=i,
+            )
+            for i in range(1, channels + 1)
+        ]
+        return parent, children
+
+    def test_101_channelized_breakout_to_discrete_interfaces(self):
+        """
+        A 4-channel parent broken out to four discrete far-end interfaces via a 1C4P:4C1P breakout cable. Each channel
+        subinterface traces to its corresponding far-end interface (and vice versa); the parent itself has no path.
+        """
+        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()
+
+        # One forward and one reverse path per channel; the parent originates no path
+        self.assertEqual(CablePath.objects.count(), 8)
+        parent.refresh_from_db()
+        self.assertPathIsNotSet(parent)
+
+        for i, (channel, far_iface) in enumerate(zip(channels, far), start=1):
+            channel.refresh_from_db()
+            far_iface.refresh_from_db()
+
+            # The parent's cable is mirrored onto the channel, restricted to its single connector position
+            self.assertEqual(channel.cable_id, cable.pk)
+            self.assertEqual(channel.cable_connector, 1)
+            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)
+
+        # The trace SVG must render from both a channel subinterface and a discrete far-end interface
+        CableTraceSVG(channels[0]).render()
+        CableTraceSVG(far[0]).render()
+
+    def test_102_channelized_to_channelized(self):
+        """
+        Two channelized interfaces connected by a single 1C4P cable (both ends channelized on one connector). Each
+        near-end channel traces to the far-end channel bound to the same position.
+        """
+        near_parent, near_channels = self._create_channelized_interface('et0', 4)
+        far_device = Device.objects.create(
+            site=self.site, device_type=self.device.device_type, role=self.device.role, name='Device 2'
+        )
+        far_parent, far_channels = self._create_channelized_interface('et0', 4, device=far_device)
+
+        cable = Cable(
+            profile=CableProfileChoices.SINGLE_1C4P,
+            a_terminations=[near_parent],
+            b_terminations=[far_parent],
+        )
+        cable.clean()
+        cable.save()
+
+        self.assertEqual(CablePath.objects.count(), 8)
+        for near, far in zip(near_channels, far_channels):
+            near.refresh_from_db()
+            far.refresh_from_db()
+            self.assertPathExists((near, cable, far), is_complete=True, is_active=True)
+            self.assertPathExists((far, cable, near), is_complete=True, is_active=True)
+
+        # The trace SVG for a channel subinterface must render, drawing the cable between the two channels. The cable
+        # terminates on the parent interfaces, so the connector is matched to the channels via their parents.
+        svg = CableTraceSVG(near_channels[0])
+        svg.render()
+        self.assertTrue(
+            any(isinstance(c, Connector) for c in svg.connectors),
+            msg="Trace SVG did not render a cable connector for the channelized path"
+        )
+
+    def test_103_add_channel_after_cabling(self):
+        """
+        On an already-cabled parent, deleting a channel subinterface tears down its path, and adding a channel
+        subinterface (re-adding one on the freed position) builds a fresh path for it in both directions.
+        """
+        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()
+
+        # Removing the fourth channel tears down its complete path in both directions
+        channels[3].delete()
+        self.assertPathDoesNotExist((channels[3], cable, far[3]))
+        self.assertPathDoesNotExist((far[3], cable, channels[3]))
+
+        # Re-adding a channel on position 4 restores the complete path in both directions
+        new_channel = Interface.objects.create(
+            device=self.device, name='et0:4', type=InterfaceTypeChoices.TYPE_CHANNEL, parent=parent, channel_id=4
+        )
+        new_channel.refresh_from_db()
+        self.assertEqual(new_channel.cable_positions, [4])
+        self.assertPathExists((new_channel, cable, far[3]), is_complete=True, is_active=True)
+        self.assertPathExists((far[3], cable, new_channel), is_complete=True, is_active=True)
+
+    def test_104_change_channel_id(self):
+        """
+        Changing a channel's channel_id re-binds it to a different connector position, in both directions.
+        """
+        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)
+        ]
+        # Delete channels 3 and 4 so their positions are free to reassign to
+        channels[2].delete()
+        channels[3].delete()
+        cable = Cable(
+            profile=CableProfileChoices.BREAKOUT_1C4P_4C1P,
+            a_terminations=[parent],
+            b_terminations=far,
+        )
+        cable.clean()
+        cable.save()
+
+        # Channel 1 initially traces to far[0]
+        self.assertPathExists((channels[0], cable, far[0]), is_complete=True, is_active=True)
+
+        # Move channel 1 to position 3
+        channels[0].channel_id = 3
+        channels[0].save()
+        channels[0].refresh_from_db()
+
+        self.assertEqual(channels[0].cable_positions, [3])
+        self.assertPathDoesNotExist((channels[0], cable, far[0]))
+        self.assertPathExists((channels[0], cable, far[2]), is_complete=True, is_active=True)
+        self.assertPathExists((far[2], cable, channels[0]), is_complete=True, is_active=True)
+
+    def test_105_incomplete_channel(self):
+        """
+        A channel whose position has no far-end termination yields an incomplete path (rather than an error).
+        """
+        parent, channels = self._create_channelized_interface('et0', 4)
+        # Only two far-end interfaces exist, on connectors 1 and 2
+        far = [
+            Interface.objects.create(device=self.device, name=f'xe{i}', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS)
+            for i in range(2)
+        ]
+        cable = Cable(
+            profile=CableProfileChoices.BREAKOUT_1C4P_4C1P,
+            a_terminations=[parent],
+            b_terminations=far,
+        )
+        cable.clean()
+        cable.save()
+
+        # Channels 1 & 2 are complete; channels 3 & 4 have no far-end termination and trace an incomplete path
+        channels[0].refresh_from_db()
+        channels[2].refresh_from_db()
+        self.assertPathExists((channels[0], cable, far[0]), is_complete=True)
+        self.assertIsNotNone(channels[2]._path_id)
+        self.assertFalse(channels[2].path.is_complete)
+
+    def test_106_cable_removal_teardown(self):
+        """
+        Removing the cable from a channelized parent tears down every channel's path and clears the mirrored cable
+        attributes from the channels.
+        """
+        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)
+
+        cable.delete()
+
+        self.assertEqual(CablePath.objects.count(), 0)
+        for channel in channels:
+            channel.refresh_from_db()
+            self.assertIsNone(channel.cable_id)
+            self.assertIsNone(channel.cable_connector)
+            self.assertIsNone(channel.cable_positions)
+            self.assertPathIsNotSet(channel)
+
+    def test_107_direct_cabling_of_channel_rejected(self):
+        """
+        A cable cannot be terminated directly to a channel subinterface.
+        """
+        parent, channels = self._create_channelized_interface('et0', 4)
+        far = Interface.objects.create(
+            device=self.device, name='xe0', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS
+        )
+        cable = Cable(a_terminations=[channels[0]], b_terminations=[far])
+        with self.assertRaises(ValidationError):
+            cable.clean()
+
+    def test_108_unprofiled_cable_not_propagated(self):
+        """
+        An unprofiled cable carries no per-channel positions, so its attributes are not mirrored onto the parent's
+        channel subinterfaces.
+        """
+        parent, channels = self._create_channelized_interface('et0', 4)
+        far = Interface.objects.create(
+            device=self.device, name='xe0', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS
+        )
+        cable = Cable(a_terminations=[parent], b_terminations=[far])
+        cable.clean()
+        cable.save()
+
+        # The parent itself is cabled, but no cable attributes are mirrored onto the channels
+        parent.refresh_from_db()
+        self.assertEqual(parent.cable_id, cable.pk)
+        for channel in channels:
+            channel.refresh_from_db()
+            self.assertIsNone(channel.cable_id)
+            self.assertIsNone(channel.cable_positions)
+
+    def test_109_change_channel_count_after_cabling(self):
+        """
+        Increasing the channel count on an already-cabled parent re-propagates the cable to its existing channel
+        subinterfaces and rebuilds their paths (the Cable itself is unchanged, so only the post_save signal fires).
+        """
+        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)
+
+        # Increase the channel count; the existing channels' paths must survive
+        parent.refresh_from_db()
+        parent.channels = 8
+        parent.save()
+
+        self.assertEqual(CablePath.objects.count(), 8)
+        for i, (channel, far_iface) in enumerate(zip(channels, far), start=1):
+            channel.refresh_from_db()
+            self.assertEqual(channel.cable_positions, [i])
+            self.assertPathExists((channel, cable, far_iface), is_complete=True, is_active=True)
+
+    def test_110_move_channel_to_uncabled_parent(self):
+        """
+        Moving a channel subinterface from a cabled parent to a channelized-but-uncabled parent tears down the
+        channel's mirrored cable attributes and its (now orphaned) path.
+        """
+        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()
+
+        # A second channelized parent with no cable
+        uncabled_parent = Interface.objects.create(
+            device=self.device, name='et1', type=InterfaceTypeChoices.TYPE_40GE_QSFP_PLUS, channels=4
+        )
+
+        # Move the first channel to the uncabled parent; its mirrored cable & path must be torn down
+        channel = channels[0]
+        channel.refresh_from_db()
+        self.assertEqual(channel.cable_id, cable.pk)
+        channel.parent = uncabled_parent
+        channel.save()
+
+        channel.refresh_from_db()
+        self.assertIsNone(channel.cable_id)
+        self.assertIsNone(channel.cable_connector)
+        self.assertIsNone(channel.cable_positions)
+        self.assertPathIsNotSet(channel)
+        self.assertPathDoesNotExist((channel, cable, far[0]))
+        self.assertPathDoesNotExist((far[0], cable, channel))
+
+
+class ChannelizedInterfaceValidationTestCase(TestCase):
+    """
+    Test validation of the channels and channel_id fields on Interface.
+    """
+
+    @classmethod
+    def setUpTestData(cls):
+        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')
+        cls.device = Device.objects.create(site=site, device_type=device_type, role=role, name='Device 1')
+        cls.parent = Interface.objects.create(
+            device=cls.device, name='et0', type=InterfaceTypeChoices.TYPE_40GE_QSFP_PLUS, channels=4
+        )
+
+    def test_valid_channel_subinterface(self):
+        interface = Interface(
+            device=self.device, name='et0:1', type=InterfaceTypeChoices.TYPE_CHANNEL, parent=self.parent, channel_id=1
+        )
+        interface.full_clean()  # Should not raise
+
+    def test_channel_type_requires_channel_id(self):
+        interface = Interface(
+            device=self.device, name='et0:1', type=InterfaceTypeChoices.TYPE_CHANNEL, parent=self.parent
+        )
+        with self.assertRaises(ValidationError):
+            interface.full_clean()
+
+    def test_channel_id_requires_channel_type(self):
+        interface = Interface(
+            device=self.device, name='et0:1', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS,
+            parent=self.parent, channel_id=1
+        )
+        with self.assertRaises(ValidationError):
+            interface.full_clean()
+
+    def test_channel_requires_parent(self):
+        interface = Interface(
+            device=self.device, name='et0:1', type=InterfaceTypeChoices.TYPE_CHANNEL, channel_id=1
+        )
+        with self.assertRaises(ValidationError):
+            interface.full_clean()
+
+    def test_channel_requires_channelized_parent(self):
+        plain_parent = Interface.objects.create(
+            device=self.device, name='xe0', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS
+        )
+        interface = Interface(
+            device=self.device, name='xe0:1', type=InterfaceTypeChoices.TYPE_CHANNEL, parent=plain_parent, channel_id=1
+        )
+        with self.assertRaises(ValidationError):
+            interface.full_clean()
+
+    def test_channel_id_within_parent_range(self):
+        interface = Interface(
+            device=self.device, name='et0:5', type=InterfaceTypeChoices.TYPE_CHANNEL, parent=self.parent, channel_id=5
+        )
+        with self.assertRaises(ValidationError):
+            interface.full_clean()
+
+    def test_channels_and_channel_id_mutually_exclusive(self):
+        interface = Interface(
+            device=self.device, name='et0:1', type=InterfaceTypeChoices.TYPE_CHANNEL,
+            parent=self.parent, channel_id=1, channels=4
+        )
+        with self.assertRaises(ValidationError):
+            interface.full_clean()
+
+    def test_channels_not_allowed_on_virtual_type(self):
+        interface = Interface(
+            device=self.device, name='vlan10', type=InterfaceTypeChoices.TYPE_VIRTUAL, channels=4
+        )
+        with self.assertRaises(ValidationError):
+            interface.full_clean()
+
+    def test_reduce_channels_below_bound_child_rejected(self):
+        # Bind a channel to the highest channel of the parent, then attempt to reduce the parent's channel count
+        Interface.objects.create(
+            device=self.device, name='et0:4', type=InterfaceTypeChoices.TYPE_CHANNEL, parent=self.parent, channel_id=4
+        )
+        self.parent.channels = 2
+        with self.assertRaises(ValidationError):
+            self.parent.full_clean()
+
+    def test_clear_channels_with_bound_child_rejected(self):
+        # De-channelizing a parent entirely must be rejected while any channel subinterface is still bound to it
+        Interface.objects.create(
+            device=self.device, name='et0:1', type=InterfaceTypeChoices.TYPE_CHANNEL, parent=self.parent, channel_id=1
+        )
+        self.parent.channels = None
+        with self.assertRaises(ValidationError):
+            self.parent.full_clean()
+
+    def test_clear_channels_without_bound_child_allowed(self):
+        # De-channelizing is permitted once no channel subinterfaces remain bound to the parent
+        self.parent.channels = None
+        self.parent.full_clean()  # Should not raise
+
+    def test_parent_channel_id_must_be_unique(self):
+        Interface.objects.create(
+            device=self.device, name='et0:1', type=InterfaceTypeChoices.TYPE_CHANNEL, parent=self.parent, channel_id=1
+        )
+        duplicate = Interface(
+            device=self.device, name='et0:1b', type=InterfaceTypeChoices.TYPE_CHANNEL, parent=self.parent, channel_id=1
+        )
+        with self.assertRaises(ValidationError):
+            duplicate.full_clean()
+
+
+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.
+    """
+
+    @classmethod
+    def setUpTestData(cls):
+        manufacturer = Manufacturer.objects.create(name='Generic', slug='generic')
+        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(
+            device_type=cls.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,
+                name=f'et0:{i}',
+                type=InterfaceTypeChoices.TYPE_CHANNEL,
+                parent=parent_template,
+                channel_id=i,
+            )
+
+    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'
+        )
+
+        # The channelized parent carries its channel count
+        parent = device.interfaces.get(name='et0')
+        self.assertEqual(parent.channels, 4)
+        self.assertIsNone(parent.channel_id)
+
+        # Each channel subinterface carries its channel ID and is bound to the instantiated parent interface
+        for i in range(1, 5):
+            channel = device.interfaces.get(name=f'et0:{i}')
+            self.assertEqual(channel.channel_id, i)
+            self.assertIsNone(channel.channels)
+            self.assertEqual(channel.parent, parent)
+
+    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
+        )
+        with self.assertRaises(ValidationError):
+            template.full_clean()
+
+    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)
+        duplicate = InterfaceTemplate(
+            device_type=self.device_type, name='et0:1b', type=InterfaceTypeChoices.TYPE_CHANNEL,
+            parent=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
+        )
+        with self.assertRaises(ValidationError):
+            template.full_clean()
+
+    def test_template_channel_requires_channelized_parent(self):
+        # A channel template bound to a non-channelized parent template is rejected
+        plain_parent = InterfaceTemplate.objects.create(
+            device_type=self.device_type, name='xe0', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS
+        )
+        template = InterfaceTemplate(
+            device_type=self.device_type, name='xe0:1', type=InterfaceTypeChoices.TYPE_CHANNEL,
+            parent=plain_parent, channel_id=1
+        )
+        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
+        template = InterfaceTemplate(
+            device_type=self.device_type, name='xe1', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS,
+            channel_id=1
+        )
+        with self.assertRaises(ValidationError):
+            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
+        with self.assertRaises(ValidationError):
+            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
+        with self.assertRaises(ValidationError):
+            parent.full_clean()
+
+
+class ChannelizedBulkCreateTestCase(ViewTestCase):
+    """
+    Test channel_id pattern expansion when bulk-creating channel subinterfaces (and interface templates) so that each
+    generated object receives a distinct channel_id.
+    """
+
+    def setUp(self):
+        super().setUp()
+        manufacturer = Manufacturer.objects.create(name='Generic', slug='generic')
+        self.device_type = DeviceType.objects.create(
+            manufacturer=manufacturer, model='Test Device', slug='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=self.device_type, role=role, name='Device 1'
+        )
+
+    def test_bulk_create_channel_subinterfaces(self):
+        parent = Interface.objects.create(
+            device=self.device, name='et0', type=InterfaceTypeChoices.TYPE_40GE_QSFP_PLUS, channels=4
+        )
+        self.add_permissions('dcim.add_interface', 'dcim.view_interface')
+
+        request_data = {
+            'device': self.device.pk,
+            'name': 'et0:[1-4]',
+            'type': InterfaceTypeChoices.TYPE_CHANNEL,
+            'parent': parent.pk,
+            'channel_id': '[1-4]',
+        }
+        response = self.client.post(reverse('dcim:interface_add'), request_data)
+        self.assertHttpStatus(response, 302)
+
+        # Four channel subinterfaces are created, each bound to a distinct channel on the parent
+        channels = Interface.objects.filter(parent=parent).order_by('channel_id')
+        self.assertEqual(channels.count(), 4)
+        for i, channel in enumerate(channels, start=1):
+            self.assertEqual(channel.name, f'et0:{i}')
+            self.assertEqual(channel.channel_id, i)
+
+    def test_bulk_create_channel_subinterface_templates(self):
+        parent = InterfaceTemplate.objects.create(
+            device_type=self.device_type, name='et0', type=InterfaceTypeChoices.TYPE_40GE_QSFP_PLUS, channels=4
+        )
+        self.add_permissions('dcim.add_interfacetemplate', 'dcim.view_interfacetemplate')
+
+        request_data = {
+            'device_type': self.device_type.pk,
+            'name': 'et0:[1-4]',
+            'type': InterfaceTypeChoices.TYPE_CHANNEL,
+            'parent': parent.pk,
+            'channel_id': '[1-4]',
+        }
+        response = self.client.post(reverse('dcim:interfacetemplate_add'), request_data)
+        self.assertHttpStatus(response, 302)
+
+        templates = InterfaceTemplate.objects.filter(parent=parent).order_by('channel_id')
+        self.assertEqual(templates.count(), 4)
+        for i, template in enumerate(templates, start=1):
+            self.assertEqual(template.name, f'et0:{i}')
+            self.assertEqual(template.channel_id, i)

+ 2 - 0
netbox/dcim/ui/panels.py

@@ -523,6 +523,7 @@ class InterfacePanel(panels.ObjectAttributesPanel):
     name = attrs.TextAttr('name')
     name = attrs.TextAttr('name')
     label = attrs.TextAttr('label')
     label = attrs.TextAttr('label')
     type = attrs.ChoiceAttr('type')
     type = attrs.ChoiceAttr('type')
+    channels = attrs.NumericAttr('channels')
     speed = attrs.TemplatedAttr('speed', template_name='dcim/interface/attrs/speed.html', label=_('Speed'))
     speed = attrs.TemplatedAttr('speed', template_name='dcim/interface/attrs/speed.html', label=_('Speed'))
     duplex = attrs.ChoiceAttr('duplex')
     duplex = attrs.ChoiceAttr('duplex')
     mtu = attrs.NumericAttr('mtu', label=_('MTU'))
     mtu = attrs.NumericAttr('mtu', label=_('MTU'))
@@ -543,6 +544,7 @@ class RelatedInterfacesPanel(panels.ObjectAttributesPanel):
     title = _('Related Interfaces')
     title = _('Related Interfaces')
 
 
     parent = attrs.RelatedObjectAttr('parent', linkify=True)
     parent = attrs.RelatedObjectAttr('parent', linkify=True)
+    channel_id = attrs.NumericAttr('channel_id', label=_('Channel ID'))
     bridge = attrs.RelatedObjectAttr('bridge', linkify=True)
     bridge = attrs.RelatedObjectAttr('bridge', linkify=True)
     lag = attrs.RelatedObjectAttr('lag', linkify=True, label=_('LAG'))
     lag = attrs.RelatedObjectAttr('lag', linkify=True, label=_('LAG'))
 
 

+ 69 - 4
netbox/dcim/utils.py

@@ -131,12 +131,29 @@ def create_cablepaths(objects):
 
 
     :param objects: Iterable of cabled objects (e.g. Interfaces)
     :param objects: Iterable of cabled objects (e.g. Interfaces)
     """
     """
-    from dcim.models import CablePath
+    from dcim.models import CablePath, Interface
 
 
-    # Arrange objects by cable connector. All objects with a null connector are grouped together.
-    origins = defaultdict(list)
+    # Expand any channelized interface into its channel subinterfaces. A channelized parent originates no path of its
+    # own; instead, each channel subinterface traces independently from the single connector position it occupies.
+    # Plain (non-channelized) origins pass through unchanged, keeping this expansion re-entrant so that
+    # rebuild_paths() -> create_cablepaths(cp.origins) does not re-expand the channel subinterfaces it already holds.
+    expanded = []
     for obj in objects:
     for obj in objects:
-        origins[obj.cable_connector].append(obj)
+        if isinstance(obj, Interface) and obj.channels:
+            expanded.extend(obj.child_interfaces.filter(channel_id__isnull=False, cable__isnull=False))
+        else:
+            expanded.append(obj)
+
+    # Arrange objects by cable connector. All objects with a null connector are grouped together. Channel
+    # subinterfaces must each originate their own path, as sharing a connector would otherwise collapse a group of
+    # siblings into a single malformed path.
+    origins = defaultdict(list)
+    for obj in expanded:
+        if isinstance(obj, Interface) and obj.channel_id:
+            if cp := CablePath.from_origin([obj]):
+                cp.save()
+        else:
+            origins[obj.cable_connector].append(obj)
 
 
     for connector, objects in origins.items():
     for connector, objects in origins.items():
         if cp := CablePath.from_origin(objects):
         if cp := CablePath.from_origin(objects):
@@ -158,6 +175,54 @@ def rebuild_paths(terminations):
                 create_cablepaths(cp.origins)
                 create_cablepaths(cp.origins)
 
 
 
 
+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.
+    """
+    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):
+            if ct.cable_end == CableEndChoices.SIDE_A:
+                a_terminations.append(ct.termination)
+            else:
+                b_terminations.append(ct.termination)
+
+        for nodes in (a_terminations, b_terminations):
+            if not nodes:
+                continue
+            if isinstance(nodes[0], PathEndpoint):
+                create_cablepaths(nodes)
+            else:
+                rebuild_paths(nodes)
+
+
+def update_interface_parents(device, interface_templates, module=None):
+    """
+    Used for device and module instantiation. Iterates all InterfaceTemplates with a parent assigned and applies it to
+    the actual interfaces. Must run after all interfaces have been instantiated (so that every parent interface exists)
+    and before update_interface_bridges() (so that channel subinterfaces validate against a populated parent).
+    """
+    Interface = apps.get_model('dcim', 'Interface')
+
+    for interface_template in interface_templates.exclude(parent=None):
+        interface = Interface.objects.get(device=device, name=interface_template.resolve_name(module=module))
+        interface.parent = Interface.objects.get(
+            device=device,
+            name=interface_template.parent.resolve_name(module=module)
+        )
+        interface.full_clean()
+        interface.save()
+
+
 def update_interface_bridges(device, interface_templates, module=None):
 def update_interface_bridges(device, interface_templates, module=None):
     """
     """
     Used for device and module instantiation. Iterates all InterfaceTemplates with a bridge assigned
     Used for device and module instantiation. Iterates all InterfaceTemplates with a bridge assigned

+ 14 - 0
netbox/utilities/forms/fields/expandable.py

@@ -10,6 +10,7 @@ from utilities.forms.utils import expand_alphanumeric_pattern, expand_ipnetwork_
 __all__ = (
 __all__ = (
     'ExpandableIPNetworkField',
     'ExpandableIPNetworkField',
     'ExpandableNameField',
     'ExpandableNameField',
+    'ExpandableNumericField',
 )
 )
 
 
 
 
@@ -35,6 +36,19 @@ class ExpandableNameField(forms.CharField):
         return [value]
         return [value]
 
 
 
 
+class ExpandableNumericField(ExpandableNameField):
+    """
+    An ExpandableNameField intended for numeric values, yielding integer-compatible strings suitable for bulk creation.
+      Example: '[1-3]' => ['1', '2', '3']
+    """
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        # Replace the inherited alphanumeric default with numeric-specific guidance (unless one was supplied)
+        if not kwargs.get('help_text'):
+            self.help_text = _("Numeric ranges are supported for bulk creation (example: <code>[1-24]</code>).")
+
+
 class ExpandableIPNetworkField(forms.CharField):
 class ExpandableIPNetworkField(forms.CharField):
     """
     """
     A CharField that expands numeric range patterns in IPv4/IPv6 CIDR notation into multiple entries.
     A CharField that expands numeric range patterns in IPv4/IPv6 CIDR notation into multiple entries.