From c429cc363819a019e705b0e9187638e0acd3c8cb Mon Sep 17 00:00:00 2001 From: Martin Hauser Date: Fri, 31 Oct 2025 22:17:58 +0100 Subject: [PATCH] Closes #14171: Add VLAN-related fields to import forms (#20730) --- netbox/dcim/forms/bulk_import.py | 53 ++++++++++++++++++-- netbox/dcim/tests/test_views.py | 17 +++++-- netbox/virtualization/forms/bulk_import.py | 58 +++++++++++++++++++--- netbox/virtualization/tests/test_views.py | 17 +++++-- 4 files changed, 126 insertions(+), 19 deletions(-) diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index 10ae701bb..dc01f26da 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -9,7 +9,8 @@ from dcim.choices import * from dcim.constants import * from dcim.models import * from extras.models import ConfigTemplate -from ipam.models import VRF, IPAddress +from ipam.choices import VLANQinQRoleChoices +from ipam.models import VLAN, VRF, IPAddress, VLANGroup from netbox.choices import * from netbox.forms import NetBoxModelImportForm from tenancy.models import Tenant @@ -17,7 +18,7 @@ from utilities.forms.fields import ( CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVModelMultipleChoiceField, CSVTypedChoiceField, SlugField, ) -from virtualization.models import Cluster, VMInterface, VirtualMachine +from virtualization.models import Cluster, VirtualMachine, VMInterface from wireless.choices import WirelessRoleChoices from .common import ModuleCommonForm @@ -938,7 +939,7 @@ class InterfaceImportForm(NetBoxModelImportForm): required=False, to_field_name='name', help_text=mark_safe( - _('VDC names separated by commas, encased with double quotes. Example:') + ' vdc1,vdc2,vdc3' + _('VDC names separated by commas, encased with double quotes. Example:') + ' "vdc1,vdc2,vdc3"' ) ) type = CSVChoiceField( @@ -967,7 +968,41 @@ class InterfaceImportForm(NetBoxModelImportForm): label=_('Mode'), choices=InterfaceModeChoices, required=False, - help_text=_('IEEE 802.1Q operational mode (for L2 interfaces)') + help_text=_('IEEE 802.1Q operational mode (for L2 interfaces)'), + ) + vlan_group = CSVModelChoiceField( + label=_('VLAN group'), + queryset=VLANGroup.objects.all(), + required=False, + to_field_name='name', + help_text=_('Filter VLANs available for assignment by group'), + ) + untagged_vlan = CSVModelChoiceField( + label=_('Untagged VLAN'), + queryset=VLAN.objects.all(), + required=False, + to_field_name='vid', + help_text=_('Assigned untagged VLAN ID (filtered by VLAN group)'), + ) + tagged_vlans = CSVModelMultipleChoiceField( + label=_('Tagged VLANs'), + queryset=VLAN.objects.all(), + required=False, + to_field_name='vid', + help_text=mark_safe( + _( + 'Assigned tagged VLAN IDs separated by commas, encased with double quotes ' + '(filtered by VLAN group). Example:' + ) + + ' "100,200,300"' + ), + ) + qinq_svlan = CSVModelChoiceField( + label=_('Q-in-Q Service VLAN'), + queryset=VLAN.objects.filter(qinq_role=VLANQinQRoleChoices.ROLE_SERVICE), + required=False, + to_field_name='vid', + help_text=_('Assigned Q-in-Q Service VLAN ID (filtered by VLAN group)'), ) vrf = CSVModelChoiceField( label=_('VRF'), @@ -988,7 +1023,8 @@ class InterfaceImportForm(NetBoxModelImportForm): fields = ( 'device', 'name', 'label', 'parent', 'bridge', 'lag', 'type', 'speed', 'duplex', 'enabled', 'mark_connected', 'wwn', 'vdcs', 'mtu', 'mgmt_only', 'description', 'poe_mode', 'poe_type', 'mode', - 'vrf', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'tags' + 'vlan_group', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vrf', 'rf_role', 'rf_channel', + 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'tags' ) def __init__(self, data=None, *args, **kwargs): @@ -1005,6 +1041,13 @@ class InterfaceImportForm(NetBoxModelImportForm): self.fields['lag'].queryset = self.fields['lag'].queryset.filter(**params) self.fields['vdcs'].queryset = self.fields['vdcs'].queryset.filter(**params) + # Limit choices for VLANs to the assigned VLAN group + if vlan_group := data.get('vlan_group'): + params = {f"group__{self.fields['vlan_group'].to_field_name}": vlan_group} + self.fields['untagged_vlan'].queryset = self.fields['untagged_vlan'].queryset.filter(**params) + self.fields['tagged_vlans'].queryset = self.fields['tagged_vlans'].queryset.filter(**params) + self.fields['qinq_svlan'].queryset = self.fields['qinq_svlan'].queryset.filter(**params) + def clean_enabled(self): # Make sure enabled is True when it's not included in the uploaded data if 'enabled' not in self.data: diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index e1ba63ded..8a3fb539c 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -2834,10 +2834,19 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): } cls.csv_data = ( - "device,name,type,vrf.pk,poe_mode,poe_type", - f"Device 1,Interface 4,1000base-t,{vrfs[0].pk},pse,type1-ieee802.3af", - f"Device 1,Interface 5,1000base-t,{vrfs[0].pk},pse,type1-ieee802.3af", - f"Device 1,Interface 6,1000base-t,{vrfs[0].pk},pse,type1-ieee802.3af", + "device,name,type,vrf.pk,poe_mode,poe_type,mode,untagged_vlan,tagged_vlans", + ( + f"Device 1,Interface 4,1000base-t,{vrfs[0].pk},pse,type1-ieee802.3af," + f"tagged,{vlans[0].vid},'{','.join([str(v.vid) for v in vlans[1:4]])}'" + ), + ( + f"Device 1,Interface 5,1000base-t,{vrfs[0].pk},pse,type1-ieee802.3af," + f"tagged,{vlans[0].vid},'{','.join([str(v.vid) for v in vlans[1:4]])}'" + ), + ( + f"Device 1,Interface 6,1000base-t,{vrfs[0].pk},pse,type1-ieee802.3af," + f"tagged,{vlans[0].vid},'{','.join([str(v.vid) for v in vlans[1:4]])}'" + ), ) cls.csv_update_data = ( diff --git a/netbox/virtualization/forms/bulk_import.py b/netbox/virtualization/forms/bulk_import.py index 6b5b62d11..56a6d4402 100644 --- a/netbox/virtualization/forms/bulk_import.py +++ b/netbox/virtualization/forms/bulk_import.py @@ -1,13 +1,18 @@ from django.utils.translation import gettext_lazy as _ +from django.utils.safestring import mark_safe from dcim.choices import InterfaceModeChoices from dcim.forms.mixins import ScopedImportForm from dcim.models import Device, DeviceRole, Platform, Site from extras.models import ConfigTemplate -from ipam.models import VRF +from ipam.choices import VLANQinQRoleChoices +from ipam.models import VLAN, VRF, VLANGroup from netbox.forms import NetBoxModelImportForm from tenancy.models import Tenant -from utilities.forms.fields import CSVChoiceField, CSVModelChoiceField, SlugField +from utilities.forms.fields import ( + CSVChoiceField, CSVModelChoiceField, CSVModelMultipleChoiceField, + SlugField, +) from virtualization.choices import * from virtualization.models import * @@ -158,20 +163,54 @@ class VMInterfaceImportForm(NetBoxModelImportForm): queryset=VMInterface.objects.all(), required=False, to_field_name='name', - help_text=_('Parent interface') + help_text=_('Parent interface'), ) bridge = CSVModelChoiceField( label=_('Bridge'), queryset=VMInterface.objects.all(), required=False, to_field_name='name', - help_text=_('Bridged interface') + help_text=_('Bridged interface'), ) mode = CSVChoiceField( label=_('Mode'), choices=InterfaceModeChoices, required=False, - help_text=_('IEEE 802.1Q operational mode (for L2 interfaces)') + help_text=_('IEEE 802.1Q operational mode (for L2 interfaces)'), + ) + vlan_group = CSVModelChoiceField( + label=_('VLAN group'), + queryset=VLANGroup.objects.all(), + required=False, + to_field_name='name', + help_text=_('Filter VLANs available for assignment by group'), + ) + untagged_vlan = CSVModelChoiceField( + label=_('Untagged VLAN'), + queryset=VLAN.objects.all(), + required=False, + to_field_name='vid', + help_text=_('Assigned untagged VLAN ID (filtered by VLAN group)'), + ) + tagged_vlans = CSVModelMultipleChoiceField( + label=_('Tagged VLANs'), + queryset=VLAN.objects.all(), + required=False, + to_field_name='vid', + help_text=mark_safe( + _( + 'Assigned tagged VLAN IDs separated by commas, encased with double quotes ' + '(filtered by VLAN group). Example:' + ) + + ' "100,200,300"' + ), + ) + qinq_svlan = CSVModelChoiceField( + label=_('Q-in-Q Service VLAN'), + queryset=VLAN.objects.filter(qinq_role=VLANQinQRoleChoices.ROLE_SERVICE), + required=False, + to_field_name='vid', + help_text=_('Assigned Q-in-Q Service VLAN ID (filtered by VLAN group)'), ) vrf = CSVModelChoiceField( label=_('VRF'), @@ -185,7 +224,7 @@ class VMInterfaceImportForm(NetBoxModelImportForm): model = VMInterface fields = ( 'virtual_machine', 'name', 'parent', 'bridge', 'enabled', 'mtu', 'description', 'mode', - 'vrf', 'tags' + 'vlan_group', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vrf', 'tags' ) def __init__(self, data=None, *args, **kwargs): @@ -200,6 +239,13 @@ class VMInterfaceImportForm(NetBoxModelImportForm): self.fields['parent'].queryset = self.fields['parent'].queryset.filter(**params) self.fields['bridge'].queryset = self.fields['bridge'].queryset.filter(**params) + # Limit choices for VLANs to the assigned VLAN group + if vlan_group := data.get('vlan_group'): + params = {f"group__{self.fields['vlan_group'].to_field_name}": vlan_group} + self.fields['untagged_vlan'].queryset = self.fields['untagged_vlan'].queryset.filter(**params) + self.fields['tagged_vlans'].queryset = self.fields['tagged_vlans'].queryset.filter(**params) + self.fields['qinq_svlan'].queryset = self.fields['qinq_svlan'].queryset.filter(**params) + def clean_enabled(self): # Make sure enabled is True when it's not included in the uploaded data if 'enabled' not in self.data: diff --git a/netbox/virtualization/tests/test_views.py b/netbox/virtualization/tests/test_views.py index 35226c16d..0c1d2b53a 100644 --- a/netbox/virtualization/tests/test_views.py +++ b/netbox/virtualization/tests/test_views.py @@ -395,10 +395,19 @@ class VMInterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): } cls.csv_data = ( - "virtual_machine,name,vrf.pk", - f"Virtual Machine 2,Interface 4,{vrfs[0].pk}", - f"Virtual Machine 2,Interface 5,{vrfs[0].pk}", - f"Virtual Machine 2,Interface 6,{vrfs[0].pk}", + "virtual_machine,name,vrf.pk,mode,untagged_vlan,tagged_vlans", + ( + f"Virtual Machine 2,Interface 4,{vrfs[0].pk}," + f"tagged,{vlans[0].vid},'{','.join([str(v.vid) for v in vlans[1:4]])}'" + ), + ( + f"Virtual Machine 2,Interface 5,{vrfs[0].pk}," + f"tagged,{vlans[0].vid},'{','.join([str(v.vid) for v in vlans[1:4]])}'" + ), + ( + f"Virtual Machine 2,Interface 6,{vrfs[0].pk}," + f"tagged,{vlans[0].vid},'{','.join([str(v.vid) for v in vlans[1:4]])}'" + ), ) cls.csv_update_data = (