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

This commit is contained in:
Jeremy Stretch 2026-07-22 12:10:45 -04:00 committed by GitHub
parent ab07002df8
commit d5dca3ae81
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
31 changed files with 1345 additions and 75 deletions

View File

@ -28,11 +28,17 @@ An alternative physical label identifying the interface.
### Type
The type of interface. Interfaces may be physical or virtual in nature, but only physical interfaces may be connected via cables.
The 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
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
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
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
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
Interfaces can be bridged to other interfaces on a device in two manners: symmetric or grouped.

View File

@ -263,7 +263,8 @@ class InterfaceSerializer(
class Meta:
model = Interface
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',
'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',

View File

@ -184,6 +184,10 @@ class InterfaceTemplateSerializer(ComponentTemplateSerializer):
default=None
)
type = ChoiceField(choices=InterfaceTypeChoices)
parent = NestedInterfaceTemplateSerializer(
required=False,
allow_null=True
)
bridge = NestedInterfaceTemplateSerializer(
required=False,
allow_null=True
@ -210,8 +214,9 @@ class InterfaceTemplateSerializer(ComponentTemplateSerializer):
class Meta:
model = InterfaceTemplate
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')

View File

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

View File

@ -48,6 +48,12 @@ PORT_POSITION_MAX = 1024
INTERFACE_MTU_MIN = 1
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 = [
InterfaceTypeChoices.TYPE_VIRTUAL,
InterfaceTypeChoices.TYPE_LAG,
@ -73,7 +79,10 @@ WIRELESS_IFACE_TYPES = [
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,
]
#

View File

@ -1058,6 +1058,11 @@ class InterfaceTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeCo
distinct=False,
null_value=None
)
parent_id = django_filters.ModelMultipleChoiceFilter(
field_name='parent',
queryset=InterfaceTemplate.objects.all(),
distinct=False,
)
bridge_id = django_filters.ModelMultipleChoiceFilter(
field_name='bridge',
queryset=InterfaceTemplate.objects.all(),
@ -1078,7 +1083,7 @@ class InterfaceTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeCo
class Meta:
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
@ -2416,9 +2421,9 @@ class InterfaceFilterSet(
class Meta:
model = Interface
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):

View File

@ -1191,6 +1191,14 @@ class InterfaceTemplateBulkEditForm(ComponentTemplateBulkEditForm):
choices=add_blank_choice(InterfaceTypeChoices),
required=False
)
channels = forms.IntegerField(
label=_('Channels'),
required=False
)
channel_id = forms.IntegerField(
label=_('Channel ID'),
required=False
)
enabled = forms.NullBooleanField(
label=_('Enabled'),
required=False,
@ -1224,7 +1232,7 @@ class InterfaceTemplateBulkEditForm(ComponentTemplateBulkEditForm):
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):
@ -1477,9 +1485,9 @@ class PowerOutletBulkEditForm(
class InterfaceBulkEditForm(
ComponentBulkEditForm,
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(
@ -1627,11 +1635,11 @@ class InterfaceBulkEditForm(
model = Interface
fieldsets = (
FieldSet('module', 'type', 'label', 'speed', 'duplex', 'description'),
FieldSet('module', 'type', 'channels', 'label', 'speed', 'duplex', 'description'),
FieldSet('vrf', 'wwn', name=_('Addressing')),
FieldSet('vdcs', 'mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected', name=_('Operation')),
FieldSet('poe_mode', 'poe_type', name=_('PoE')),
FieldSet('parent', 'bridge', 'lag', name=_('Related Interfaces')),
FieldSet('parent', 'channel_id', 'bridge', 'lag', name=_('Related Interfaces')),
FieldSet(
'mode', 'vlan_group', 'untagged_vlan', 'qinq_svlan', 'vlan_translation_policy', name=_('802.1Q Switching')
),
@ -1647,9 +1655,9 @@ class InterfaceBulkEditForm(
),
)
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):

View File

@ -1080,9 +1080,9 @@ class InterfaceImportForm(OwnerCSVMixin, NetBoxModelImportForm):
class Meta:
model = Interface
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'
)

View File

@ -1687,7 +1687,10 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
model = Interface
fieldsets = (
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('poe_mode', 'poe_type', name=_('PoE')),
FieldSet('mode', 'vlan_translation_policy_id', name=_('802.1Q Switching')),
@ -1720,6 +1723,14 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
choices=InterfaceTypeChoices,
required=False
)
channels = forms.IntegerField(
label=_('Channels'),
required=False
)
channel_id = forms.IntegerField(
label=_('Channel ID'),
required=False
)
speed = PositiveBigIntegerField(
label=_('Speed'),
required=False,

View File

@ -1391,6 +1391,15 @@ class InterfaceTemplateForm(ModularComponentTemplateForm):
choices=add_blank_choice(WirelessRoleChoices),
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(
label=_('Bridge'),
queryset=InterfaceTemplate.objects.all(),
@ -1407,7 +1416,8 @@ class InterfaceTemplateForm(ModularComponentTemplateForm):
FieldSet('device_type', name=_('Device 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('rf_role', name=_('Wireless')),
@ -1416,8 +1426,8 @@ class InterfaceTemplateForm(ModularComponentTemplateForm):
class Meta:
model = InterfaceTemplate
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 = (
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('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(
'mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vlan_translation_policy',
@ -1959,11 +1970,11 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
class Meta:
model = Interface
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 = {
'speed': NumberWithOptions(

View File

@ -4,7 +4,12 @@ from django.utils.translation import gettext_lazy as _
from dcim.models import *
from netbox.forms import NetBoxModelForm
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.widgets import APISelect
@ -105,9 +110,14 @@ class PowerOutletTemplateCreateForm(ComponentCreateForm, model_forms.PowerOutlet
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):
exclude = ('name', 'label')
exclude = ('name', 'label', 'channel_id')
class FrontPortTemplateCreateForm(ComponentCreateForm, model_forms.FrontPortTemplateForm):
@ -197,9 +207,14 @@ class PowerOutletCreateForm(ComponentCreateForm, model_forms.PowerOutletForm):
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):
exclude = ('name', 'label')
exclude = ('name', 'label', 'channel_id')
class FrontPortCreateForm(ComponentCreateForm, model_forms.FrontPortForm):

View File

@ -104,8 +104,8 @@ class InterfaceTemplateImportForm(forms.ModelForm):
class Meta:
model = InterfaceTemplate
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'
]

View File

@ -540,6 +540,12 @@ class InterfaceFilter(
type: BaseFilterLookup[Annotated['InterfaceTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
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()
speed: Annotated['BigIntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
strawberry_django.filter_field()
@ -629,8 +635,18 @@ class InterfaceTemplateFilter(ModularComponentTemplateFilterMixin, ChangeLoggedM
type: BaseFilterLookup[Annotated['InterfaceTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
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()
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 = (
strawberry_django.filter_field()
)

View File

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

View File

@ -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',
),
),
]

View File

@ -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',
),
),
]

View File

@ -636,6 +636,14 @@ class CableTermination(ChangeLoggedModel):
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)
if self.termination_type.model == 'interface' and self.termination.type in NONCONNECTABLE_IFACE_TYPES:
raise ValidationError(
@ -971,6 +979,12 @@ class CablePath(models.Model):
peer_results = cable_profile.get_peer_terminations(term_position_pairs)
seen = set()
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.
key = None if peer is None else (peer._meta.concrete_model, peer.pk)
if key not in seen:

View File

@ -454,6 +454,26 @@ class InterfaceTemplate(InterfaceValidationMixin, ModularComponentTemplateModel)
max_length=50,
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(
verbose_name=_('enabled'),
default=True
@ -462,6 +482,14 @@ class InterfaceTemplate(InterfaceValidationMixin, ModularComponentTemplateModel)
default=False,
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(
to='self',
on_delete=models.SET_NULL,
@ -495,12 +523,41 @@ class InterfaceTemplate(InterfaceValidationMixin, ModularComponentTemplateModel)
component_model = Interface
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_plural = _('interface templates')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Cache the original channel count for use by InterfaceValidationMixin.clean() to detect a channel-count
# reduction that would orphan a bound subinterface.
self._original_channels = self.__dict__.get('channels')
def clean(self):
super().clean()
# 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.device_type and self.device_type != self.bridge.device_type:
raise ValidationError({
@ -520,6 +577,8 @@ class InterfaceTemplate(InterfaceValidationMixin, ModularComponentTemplateModel)
name=self.resolve_name(kwargs.get('module'), kwargs.get('device')),
label=self.resolve_label(kwargs.get('module'), kwargs.get('device')),
type=self.type,
channels=self.channels,
channel_id=self.channel_id,
enabled=self.enabled,
mgmt_only=self.mgmt_only,
poe_mode=self.poe_mode,
@ -533,10 +592,13 @@ class InterfaceTemplate(InterfaceValidationMixin, ModularComponentTemplateModel)
return {
'name': self.name,
'type': self.type,
'channels': self.channels,
'channel_id': self.channel_id,
'enabled': self.enabled,
'mgmt_only': self.mgmt_only,
'label': self.label,
'description': self.description,
'parent': self.parent.name if self.parent else None,
'bridge': self.bridge.name if self.bridge else None,
'poe_mode': self.poe_mode,
'poe_type': self.poe_type,

View File

@ -860,6 +860,26 @@ class Interface(
max_length=50,
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(
default=False,
verbose_name=_('management only'),
@ -989,14 +1009,33 @@ class Interface(
)
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):
ordering = ('device', CollateAsChar('_name'))
verbose_name = _('interface')
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):
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
if self.parent and self.parent.device != self.device:
@ -1147,7 +1178,9 @@ class Interface(
@property
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
def is_virtual(self):
@ -1165,6 +1198,10 @@ class Interface(
def is_bridge(self):
return self.type == InterfaceTypeChoices.TYPE_BRIDGE
@property
def is_channel(self):
return self.type == InterfaceTypeChoices.TYPE_CHANNEL
@property
def link(self):
return self.cable or self.wireless_link
@ -1192,6 +1229,58 @@ class Interface(
return self.virtual_circuit_termination.peer_terminations
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

View File

@ -19,7 +19,7 @@ from django.utils.translation import gettext_lazy as _
from dcim.choices import *
from dcim.constants import *
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.querysets import ConfigContextModelQuerySet
from netbox.choices import ColorChoices
@ -1070,7 +1070,9 @@ class Device(
self._instantiate_components(self.device_type.devicebaytemplates.all())
# Disable bulk_create to accommodate MPTT
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 Site and Rack assignment for any child Devices

View File

@ -4,7 +4,8 @@ from django.core.exceptions import ValidationError
from django.db import models
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__ = (
'CachedScopeMixin',
@ -130,6 +131,81 @@ class InterfaceValidationMixin:
def clean(self):
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
if self.pk and self.bridge_id == self.pk:
raise ValidationError({'bridge': _("An interface cannot be bridged to itself.")})

View File

@ -9,7 +9,7 @@ from django.utils.translation import gettext_lazy as _
from jsonschema.exceptions import ValidationError as JSONValidationError
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 netbox.models import PrimaryModel
from netbox.models.features import ImageAttachmentsMixin
@ -591,7 +591,9 @@ class Module(TrackingModelMixin, PrimaryModel):
# Replicate any front/rear port mappings from the ModuleType
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)
def _save_existing(self, *args, **kwargs):

View File

@ -23,7 +23,7 @@ from .models import (
)
from .models.cables import trace_paths
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
@ -156,6 +156,14 @@ def nullify_connected_endpoints(instance, **kwargs):
model = instance.termination_type.model_class()
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
# per-termination retrace; retrace_cable_paths() will retrace each affected
# path once after the Cable is deleted.
@ -171,6 +179,66 @@ def nullify_connected_endpoints(instance, **kwargs):
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=VMInterface)
def update_mac_address_interface(instance, created, raw, **kwargs):

View File

@ -19,6 +19,22 @@ FANOUT_LEG_HEIGHT = 15
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):
"""
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()}")
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):
# 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):
labels = [f"{cable}"] if len(links) > 2 else [f"Wireless {cable}", cable.get_status_display()]
if cable.ssid:

View File

@ -710,12 +710,12 @@ class InterfaceTable(BaseInterfaceTable, ModularDeviceComponentTable, PathEndpoi
class Meta(DeviceComponentTable.Meta):
model = models.Interface
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',
)
default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description')

View File

@ -244,8 +244,8 @@ class InterfaceTemplateTable(ComponentTemplateTable):
class Meta(ComponentTemplateTable.Meta):
model = models.InterfaceTemplate
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"

View File

@ -1372,9 +1372,11 @@ class InterfaceTemplateTestCase(APIViewTestCases.APIViewTestCase):
interface_templates = (
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 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)
channelized_parent = interface_templates[2]
cls.create_data = [
{
@ -1397,6 +1399,21 @@ class InterfaceTemplateTestCase(APIViewTestCases.APIViewTestCase):
'name': 'Interface Template 7',
'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 = (
Interface(device=device, name='Interface 1', 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)
channelized_parent = interfaces[2]
vdcs = (
VirtualDeviceContext(name='VDC 1', identifier=1, device=device),
@ -3013,6 +3032,21 @@ class InterfaceTestCase(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTest
'rf_channel': "",
'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 = {}):

View File

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

View File

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

View File

@ -131,12 +131,29 @@ def create_cablepaths(objects):
: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:
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():
if cp := CablePath.from_origin(objects):
@ -158,6 +175,54 @@ def rebuild_paths(terminations):
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):
"""
Used for device and module instantiation. Iterates all InterfaceTemplates with a bridge assigned

View File

@ -10,6 +10,7 @@ from utilities.forms.utils import expand_alphanumeric_pattern, expand_ipnetwork_
__all__ = (
'ExpandableIPNetworkField',
'ExpandableNameField',
'ExpandableNumericField',
)
@ -35,6 +36,19 @@ class ExpandableNameField(forms.CharField):
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):
"""
A CharField that expands numeric range patterns in IPv4/IPv6 CIDR notation into multiple entries.