From e1a86139dc8b4f092bbdb03b1f5b371b911e87e0 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 5 Mar 2021 13:49:41 -0500 Subject: [PATCH] Add parent field to Interface --- netbox/dcim/api/serializers.py | 10 +++-- netbox/dcim/api/views.py | 2 +- netbox/dcim/filters.py | 5 +++ netbox/dcim/forms.py | 43 ++++++++++++++----- .../dcim/migrations/0129_interface_parent.py | 17 ++++++++ netbox/dcim/models/device_components.py | 34 ++++++++++++++- netbox/dcim/tables/devices.py | 14 +++--- netbox/templates/dcim/interface.html | 12 +++++- 8 files changed, 114 insertions(+), 23 deletions(-) create mode 100644 netbox/dcim/migrations/0129_interface_parent.py diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 469cea2a5..faeaaa11a 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -598,6 +598,7 @@ class InterfaceSerializer(TaggedObjectSerializer, CableTerminationSerializer, Co url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail') device = NestedDeviceSerializer() type = ChoiceField(choices=InterfaceTypeChoices) + parent = NestedInterfaceSerializer(required=False, allow_null=True) lag = NestedInterfaceSerializer(required=False, allow_null=True) mode = ChoiceField(choices=InterfaceModeChoices, allow_blank=True, required=False) untagged_vlan = NestedVLANSerializer(required=False, allow_null=True) @@ -613,10 +614,11 @@ class InterfaceSerializer(TaggedObjectSerializer, CableTerminationSerializer, Co class Meta: model = Interface fields = [ - 'id', 'url', 'device', 'name', 'label', 'type', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', - 'description', 'mode', 'untagged_vlan', 'tagged_vlans', 'mark_connected', 'cable', 'cable_peer', - 'cable_peer_type', 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', - 'custom_fields', 'created', 'last_updated', 'count_ipaddresses', '_occupied', + 'id', 'url', 'device', 'name', 'label', 'type', 'enabled', 'parent', 'lag', 'mtu', 'mac_address', + 'mgmt_only', 'description', 'mode', 'untagged_vlan', 'tagged_vlans', 'mark_connected', 'cable', + 'cable_peer', 'cable_peer_type', 'connected_endpoint', 'connected_endpoint_type', + 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', 'count_ipaddresses', + '_occupied', ] def validate(self, data): diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index a05076591..e869749db 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -522,7 +522,7 @@ class PowerOutletViewSet(PathEndpointMixin, ModelViewSet): class InterfaceViewSet(PathEndpointMixin, ModelViewSet): queryset = Interface.objects.prefetch_related( - 'device', '_path__destination', 'cable', '_cable_peer', 'ip_addresses', 'tags' + 'device', 'parent', 'lag', '_path__destination', 'cable', '_cable_peer', 'ip_addresses', 'tags' ) serializer_class = serializers.InterfaceSerializer filterset_class = filters.InterfaceFilterSet diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index d7db93666..6eef1671e 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -844,6 +844,11 @@ class InterfaceFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminati method='filter_kind', label='Kind of interface', ) + parent_id = django_filters.ModelMultipleChoiceFilter( + field_name='parent', + queryset=Interface.objects.all(), + label='Parent interface (ID)', + ) lag_id = django_filters.ModelMultipleChoiceFilter( field_name='lag', queryset=Interface.objects.all(), diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 9a195b75e..45ce2e1c2 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -2830,8 +2830,8 @@ class InterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm): class Meta: model = Interface fields = [ - 'device', 'name', 'label', 'type', 'enabled', 'lag', 'mac_address', 'mtu', 'mgmt_only', 'mark_connected', - 'description', 'mode', 'untagged_vlan', 'tagged_vlans', 'tags', + 'device', 'name', 'label', 'type', 'enabled', 'parent', 'lag', 'mac_address', 'mtu', 'mgmt_only', + 'mark_connected', 'description', 'mode', 'untagged_vlan', 'tagged_vlans', 'tags', ] widgets = { 'device': forms.HiddenInput(), @@ -2854,10 +2854,16 @@ class InterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm): else: device = self.instance.device - # Limit LAG choices to interfaces belonging to this device or a peer VC member device_query = Q(device=device) if device.virtual_chassis: device_query |= Q(device__virtual_chassis=device.virtual_chassis) + + # Limit parent interface choices to interfaces belonging to this device or a peer VC member + self.fields['parent'].queryset = Interface.objects.filter(device_query).exclude( + type__in=(InterfaceTypeChoices.TYPE_VIRTUAL, InterfaceTypeChoices.TYPE_LAG) + ).exclude(pk=self.instance.pk) + + # Limit LAG choices to interfaces belonging to this device or a peer VC member self.fields['lag'].queryset = Interface.objects.filter( device_query, type=InterfaceTypeChoices.TYPE_LAG @@ -2878,6 +2884,12 @@ class InterfaceCreateForm(ComponentCreateForm, InterfaceCommonForm): required=False, initial=True ) + parent = forms.ModelChoiceField( + queryset=Interface.objects.all(), + required=False, + label='Parent interface', + widget=StaticSelect2(), + ) lag = forms.ModelChoiceField( queryset=Interface.objects.all(), required=False, @@ -2923,20 +2935,25 @@ class InterfaceCreateForm(ComponentCreateForm, InterfaceCommonForm): } ) field_order = ( - 'device', 'name_pattern', 'label_pattern', 'type', 'enabled', 'lag', 'mtu', 'mac_address', 'description', - 'mgmt_only', 'mark_connected', 'mode', 'untagged_vlan', 'tagged_vlans', 'tags' + 'device', 'name_pattern', 'label_pattern', 'type', 'enabled', 'parent', 'lag', 'mtu', 'mac_address', + 'description', 'mgmt_only', 'mark_connected', 'mode', 'untagged_vlan', 'tagged_vlans', 'tags' ) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - - # Limit LAG choices to interfaces belonging to this device or a peer VC member device = Device.objects.get( pk=self.initial.get('device') or self.data.get('device') ) device_query = Q(device=device) if device.virtual_chassis: device_query |= Q(device__virtual_chassis=device.virtual_chassis) + + # Limit parent interface choices to interfaces belonging to this device or a peer VC member + self.fields['parent'].queryset = Interface.objects.filter(device_query).exclude( + type__in=(InterfaceTypeChoices.TYPE_VIRTUAL, InterfaceTypeChoices.TYPE_LAG) + ) + + # Limit LAG choices to interfaces belonging to this device or a peer VC member self.fields['lag'].queryset = Interface.objects.filter(device_query, type=InterfaceTypeChoices.TYPE_LAG) # Add current site to VLANs query params @@ -2956,7 +2973,7 @@ class InterfaceBulkCreateForm( class InterfaceBulkEditForm( form_from_model(Interface, [ - 'label', 'type', 'lag', 'mac_address', 'mtu', 'mgmt_only', 'mark_connected', 'description', 'mode' + 'label', 'type', 'parent', 'lag', 'mac_address', 'mtu', 'mgmt_only', 'mark_connected', 'description', 'mode', ]), BootstrapMixin, AddRemoveTagsForm, @@ -3006,7 +3023,7 @@ class InterfaceBulkEditForm( class Meta: nullable_fields = [ - 'label', 'lag', 'mac_address', 'mtu', 'description', 'mode', 'untagged_vlan', 'tagged_vlans' + 'label', 'parent', 'lag', 'mac_address', 'mtu', 'description', 'mode', 'untagged_vlan', 'tagged_vlans' ] def __init__(self, *args, **kwargs): @@ -3024,7 +3041,7 @@ class InterfaceBulkEditForm( self.fields['untagged_vlan'].widget.add_query_param('site_id', device.site.pk) self.fields['tagged_vlans'].widget.add_query_param('site_id', device.site.pk) else: - # See 4523 + # See #4523 if 'pk' in self.initial: site = None interfaces = Interface.objects.filter(pk__in=self.initial['pk']).prefetch_related('device__site') @@ -3064,6 +3081,12 @@ class InterfaceCSVForm(CustomFieldModelCSVForm): queryset=Device.objects.all(), to_field_name='name' ) + parent = CSVModelChoiceField( + queryset=Interface.objects.all(), + required=False, + to_field_name='name', + help_text='Parent interface' + ) lag = CSVModelChoiceField( queryset=Interface.objects.all(), required=False, diff --git a/netbox/dcim/migrations/0129_interface_parent.py b/netbox/dcim/migrations/0129_interface_parent.py new file mode 100644 index 000000000..37e722f0a --- /dev/null +++ b/netbox/dcim/migrations/0129_interface_parent.py @@ -0,0 +1,17 @@ +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0128_device_location_populate'), + ] + + operations = [ + migrations.AddField( + model_name='interface', + name='parent', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='child_interfaces', to='dcim.interface'), + ), + ] diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 4a027b373..2625219d8 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -523,6 +523,14 @@ class Interface(ComponentModel, BaseInterface, CableTermination, PathEndpoint): max_length=100, blank=True ) + parent = models.ForeignKey( + to='self', + on_delete=models.SET_NULL, + related_name='child_interfaces', + null=True, + blank=True, + verbose_name='Parent interface' + ) lag = models.ForeignKey( to='self', on_delete=models.SET_NULL, @@ -563,8 +571,8 @@ class Interface(ComponentModel, BaseInterface, CableTermination, PathEndpoint): tags = TaggableManager(through=TaggedItem) csv_headers = [ - 'device', 'name', 'label', 'lag', 'type', 'enabled', 'mark_connected', 'mac_address', 'mtu', 'mgmt_only', - 'description', 'mode', + 'device', 'name', 'label', 'parent', 'lag', 'type', 'enabled', 'mark_connected', 'mac_address', 'mtu', + 'mgmt_only', 'description', 'mode', ] class Meta: @@ -579,6 +587,7 @@ class Interface(ComponentModel, BaseInterface, CableTermination, PathEndpoint): self.device.identifier if self.device else None, self.name, self.label, + self.parent.name if self.parent else None, self.lag.name if self.lag else None, self.get_type_display(), self.enabled, @@ -602,6 +611,27 @@ class Interface(ComponentModel, BaseInterface, CableTermination, PathEndpoint): "Disconnect the interface or choose a suitable type." }) + # An interface's parent must belong to the same device or virtual chassis + if self.parent and self.parent.device != self.device: + if self.device.virtual_chassis is None: + raise ValidationError({ + 'parent': f"The selected parent interface ({self.parent}) belongs to a different device " + f"({self.parent.device})." + }) + elif self.parent.device.virtual_chassis != self.parent.virtual_chassis: + raise ValidationError({ + 'parent': f"The selected parent interface ({self.parent}) belongs to {self.parent.device}, which " + f"is not part of virtual chassis {self.device.virtual_chassis}." + }) + + # 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."}) + + # A virtual interface cannot be a parent interface + if self.parent is not None and self.parent.type == InterfaceTypeChoices.TYPE_VIRTUAL: + raise ValidationError({'parent': "Virtual interfaces may not be parents of other interfaces."}) + # An interface's LAG must belong to the same device or virtual chassis if self.lag and self.lag.device != self.device: if self.device.virtual_chassis is None: diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index 8a3944a28..49ff12190 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -436,6 +436,10 @@ class DeviceInterfaceTable(InterfaceTable): '{% endif %}"> {{ value }}', attrs={'td': {'class': 'text-nowrap'}} ) + parent = tables.Column( + linkify=True, + verbose_name='Parent' + ) lag = tables.Column( linkify=True, verbose_name='LAG' @@ -449,13 +453,13 @@ class DeviceInterfaceTable(InterfaceTable): class Meta(DeviceComponentTable.Meta): model = Interface fields = ( - 'pk', 'name', 'label', 'enabled', 'type', 'lag', 'mgmt_only', 'mtu', 'mode', 'mac_address', 'description', - 'mark_connected', 'cable', 'cable_peer', 'connection', 'tags', 'ip_addresses', 'untagged_vlan', - 'tagged_vlans', 'actions', + 'pk', 'name', 'label', 'enabled', 'type', 'parent', 'lag', 'mgmt_only', 'mtu', 'mode', 'mac_address', + 'description', 'mark_connected', 'cable', 'cable_peer', 'connection', 'tags', 'ip_addresses', + 'untagged_vlan', 'tagged_vlans', 'actions', ) default_columns = ( - 'pk', 'name', 'label', 'enabled', 'type', 'lag', 'mtu', 'mode', 'description', 'ip_addresses', 'cable', - 'connection', 'actions', + 'pk', 'name', 'label', 'enabled', 'type', 'parent', 'lag', 'mtu', 'mode', 'description', 'ip_addresses', + 'cable', 'connection', 'actions', ) row_attrs = { 'class': lambda record: record.cable.get_status_class() if record.cable else '', diff --git a/netbox/templates/dcim/interface.html b/netbox/templates/dcim/interface.html index dbd66c7e7..7511975b1 100644 --- a/netbox/templates/dcim/interface.html +++ b/netbox/templates/dcim/interface.html @@ -38,10 +38,20 @@ {% endif %} + + Parent + + {% if object.parent %} + {{ object.parent }} + {% else %} + None + {% endif %} + + LAG - {% if object.lag%} + {% if object.lag %} {{ object.lag }} {% else %} None