parent
ab07002df8
commit
d5dca3ae81
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
||||
|
|
|
|||
|
|
@ -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')),
|
||||
),
|
||||
),
|
||||
(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]
|
||||
|
||||
|
||||
#
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
@ -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',
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.")})
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = {}):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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'))
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue