From e66695b0599cc87d8b46021f77e3a7cc41c3f432 Mon Sep 17 00:00:00 2001 From: Brian Tiemann Date: Tue, 17 Sep 2024 15:57:09 -0400 Subject: [PATCH 1/5] Allow adding/removing tagged VLANs in bulk editing of Interfaces --- netbox/dcim/forms/bulk_edit.py | 54 ++++++++++++++++------- netbox/netbox/views/generic/bulk_views.py | 6 +++ 2 files changed, 44 insertions(+), 16 deletions(-) diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index 96036f4da1..6a94a691b8 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -1404,18 +1404,25 @@ class InterfaceBulkEditForm( parent = DynamicModelChoiceField( label=_('Parent'), queryset=Interface.objects.all(), - required=False + required=False, + query_params={ + 'virtual_chassis_member_id': '$device', + } ) bridge = DynamicModelChoiceField( label=_('Bridge'), queryset=Interface.objects.all(), - required=False + required=False, + query_params={ + 'virtual_chassis_member_id': '$device', + } ) lag = DynamicModelChoiceField( queryset=Interface.objects.all(), required=False, query_params={ 'type': 'lag', + 'virtual_chassis_member_id': '$device', }, label=_('LAG') ) @@ -1472,6 +1479,7 @@ class InterfaceBulkEditForm( required=False, query_params={ 'group_id': '$vlan_group', + 'available_on_device': '$device', }, label=_('Untagged VLAN') ) @@ -1480,9 +1488,28 @@ class InterfaceBulkEditForm( required=False, query_params={ 'group_id': '$vlan_group', + 'available_on_device': '$device', }, label=_('Tagged VLANs') ) + add_tagged_vlans = DynamicModelMultipleChoiceField( + label=_('Add tagged VLANs'), + queryset=VLAN.objects.all(), + required=False, + query_params={ + 'group_id': '$vlan_group', + 'available_on_device': '$device', + }, + ) + remove_tagged_vlans = DynamicModelMultipleChoiceField( + label=_('Remove tagged VLANs'), + queryset=VLAN.objects.all(), + required=False, + query_params={ + 'group_id': '$vlan_group', + 'available_on_device': '$device', + } + ) vrf = DynamicModelChoiceField( queryset=VRF.objects.all(), required=False, @@ -1509,7 +1536,7 @@ class InterfaceBulkEditForm( 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('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans', name=_('802.1Q Switching')), + FieldSet('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans', 'add_tagged_vlans', 'remove_tagged_vlans', name=_('802.1Q Switching')), FieldSet( 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'wireless_lan_group', 'wireless_lans', name=_('Wireless') @@ -1523,19 +1550,7 @@ class InterfaceBulkEditForm( def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - if self.device_id: - device = Device.objects.filter(pk=self.device_id).first() - - # Restrict parent/bridge/LAG interface assignment by device - self.fields['parent'].widget.add_query_param('virtual_chassis_member_id', device.pk) - self.fields['bridge'].widget.add_query_param('virtual_chassis_member_id', device.pk) - self.fields['lag'].widget.add_query_param('virtual_chassis_member_id', device.pk) - - # Limit VLAN choices by device - self.fields['untagged_vlan'].widget.add_query_param('available_on_device', device.pk) - self.fields['tagged_vlans'].widget.add_query_param('available_on_device', device.pk) - - else: + if not self.device_id: # See #4523 if 'pk' in self.initial: site = None @@ -1559,6 +1574,13 @@ def __init__(self, *args, **kwargs): 'site_id', [site.pk, settings.FILTERS_NULL_CHOICE_VALUE] ) + self.fields['add_tagged_vlans'].widget.add_query_param( + 'site_id', [site.pk, settings.FILTERS_NULL_CHOICE_VALUE] + ) + self.fields['remove_tagged_vlans'].widget.add_query_param( + 'site_id', [site.pk, settings.FILTERS_NULL_CHOICE_VALUE] + ) + self.fields['parent'].choices = () self.fields['parent'].widget.attrs['disabled'] = True self.fields['bridge'].choices = () diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py index d7d28b95f5..c3478e316d 100644 --- a/netbox/netbox/views/generic/bulk_views.py +++ b/netbox/netbox/views/generic/bulk_views.py @@ -615,6 +615,12 @@ def _update_objects(self, form, request): if form.cleaned_data.get('remove_tags', None): obj.tags.remove(*form.cleaned_data['remove_tags']) + # Add/remove tagged VLANs + if form.cleaned_data.get('add_tagged_vlans', None): + obj.tagged_vlans.add(*form.cleaned_data['add_tagged_vlans']) + if form.cleaned_data.get('remove_tagged_vlans', None): + obj.tagged_vlans.remove(*form.cleaned_data['remove_tagged_vlans']) + # Rebuild the tree for MPTT models if issubclass(self.queryset.model, MPTTModel): self.queryset.model.objects.rebuild() From c4aac4ea42447d9a6217dd8cab767926041b9d4c Mon Sep 17 00:00:00 2001 From: Brian Tiemann Date: Thu, 19 Sep 2024 11:01:37 -0400 Subject: [PATCH 2/5] Move vlan/interface-specific field operations to an overrideable method --- netbox/dcim/views.py | 7 +++++++ netbox/netbox/views/generic/bulk_views.py | 9 ++++----- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 1f5b736039..c4bca327f7 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -2616,6 +2616,13 @@ class InterfaceBulkEditView(generic.BulkEditView): table = tables.InterfaceTable form = forms.InterfaceBulkEditForm + def extra_object_field_operations(self, form, obj): + # Add/remove tagged VLANs + if form.cleaned_data.get('add_tagged_vlans', None): + obj.tagged_vlans.add(*form.cleaned_data['add_tagged_vlans']) + if form.cleaned_data.get('remove_tagged_vlans', None): + obj.tagged_vlans.remove(*form.cleaned_data['remove_tagged_vlans']) + class InterfaceBulkRenameView(generic.BulkRenameView): queryset = Interface.objects.all() diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py index c3478e316d..c5582e1b05 100644 --- a/netbox/netbox/views/generic/bulk_views.py +++ b/netbox/netbox/views/generic/bulk_views.py @@ -541,6 +541,9 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView): def get_required_permission(self): return get_permission_for_model(self.queryset.model, 'change') + def extra_object_field_operations(self, form, obj): + pass + def _update_objects(self, form, request): custom_fields = getattr(form, 'custom_fields', {}) standard_fields = [ @@ -615,11 +618,7 @@ def _update_objects(self, form, request): if form.cleaned_data.get('remove_tags', None): obj.tags.remove(*form.cleaned_data['remove_tags']) - # Add/remove tagged VLANs - if form.cleaned_data.get('add_tagged_vlans', None): - obj.tagged_vlans.add(*form.cleaned_data['add_tagged_vlans']) - if form.cleaned_data.get('remove_tagged_vlans', None): - obj.tagged_vlans.remove(*form.cleaned_data['remove_tagged_vlans']) + self.extra_object_field_operations(form, obj) # Rebuild the tree for MPTT models if issubclass(self.queryset.model, MPTTModel): From 6fa295bc2f7fe646d958aa1138df0612679cd853 Mon Sep 17 00:00:00 2001 From: Brian Tiemann Date: Mon, 23 Sep 2024 19:04:16 -0400 Subject: [PATCH 3/5] Ensure interfaces are MODE_TAGGED before adding/removing tagged vlans --- netbox/dcim/views.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index c4bca327f7..8036178ed6 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -35,7 +35,7 @@ from virtualization.models import VirtualMachine from virtualization.tables import VirtualMachineTable from . import filtersets, forms, tables -from .choices import DeviceFaceChoices +from .choices import DeviceFaceChoices, InterfaceModeChoices from .models import * CABLE_TERMINATION_TYPES = { @@ -2618,10 +2618,11 @@ class InterfaceBulkEditView(generic.BulkEditView): def extra_object_field_operations(self, form, obj): # Add/remove tagged VLANs - if form.cleaned_data.get('add_tagged_vlans', None): - obj.tagged_vlans.add(*form.cleaned_data['add_tagged_vlans']) - if form.cleaned_data.get('remove_tagged_vlans', None): - obj.tagged_vlans.remove(*form.cleaned_data['remove_tagged_vlans']) + if obj.mode == InterfaceModeChoices.MODE_TAGGED: + if form.cleaned_data.get('add_tagged_vlans', None): + obj.tagged_vlans.add(*form.cleaned_data['add_tagged_vlans']) + if form.cleaned_data.get('remove_tagged_vlans', None): + obj.tagged_vlans.remove(*form.cleaned_data['remove_tagged_vlans']) class InterfaceBulkRenameView(generic.BulkRenameView): From 8fe1b745a646c85bc2aed7057b97ba9d79759021 Mon Sep 17 00:00:00 2001 From: Brian Tiemann Date: Thu, 3 Oct 2024 12:36:18 -0400 Subject: [PATCH 4/5] Add docstring for generic extra_object_field_operations --- netbox/netbox/views/generic/bulk_views.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py index c5582e1b05..19c66cc67a 100644 --- a/netbox/netbox/views/generic/bulk_views.py +++ b/netbox/netbox/views/generic/bulk_views.py @@ -542,6 +542,10 @@ def get_required_permission(self): return get_permission_for_model(self.queryset.model, 'change') def extra_object_field_operations(self, form, obj): + """ + This method is called for each object in _update_objects. Override to perform additional object-level + operations that are specific to a particular ModelForm. + """ pass def _update_objects(self, form, request): From 56664ab0fe262808df274fe4a98816f30d6f3300 Mon Sep 17 00:00:00 2001 From: Brian Tiemann Date: Wed, 6 Nov 2024 13:02:35 -0500 Subject: [PATCH 5/5] Move tagging ops into post_save_operations and use a TabbedGroup in the form --- netbox/dcim/forms/bulk_edit.py | 10 ++++++++-- netbox/dcim/views.py | 4 +++- netbox/netbox/views/generic/bulk_views.py | 16 +++++++--------- 3 files changed, 18 insertions(+), 12 deletions(-) diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index 6a94a691b8..54cc4dd87e 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -13,7 +13,7 @@ from users.models import User from utilities.forms import BulkEditForm, add_blank_choice, form_from_model from utilities.forms.fields import ColorField, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField -from utilities.forms.rendering import FieldSet, InlineFields +from utilities.forms.rendering import FieldSet, InlineFields, TabbedGroups from utilities.forms.widgets import BulkEditNullBooleanSelect, NumberWithOptions from wireless.models import WirelessLAN, WirelessLANGroup from wireless.choices import WirelessRoleChoices @@ -1536,7 +1536,13 @@ class InterfaceBulkEditForm( 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('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans', 'add_tagged_vlans', 'remove_tagged_vlans', name=_('802.1Q Switching')), + FieldSet('mode', 'vlan_group', 'untagged_vlan', name=_('802.1Q Switching')), + FieldSet( + TabbedGroups( + FieldSet('tagged_vlans', name=_('Assignment')), + FieldSet('add_tagged_vlans', 'remove_tagged_vlans', name=_('Add/Remove')), + ), + ), FieldSet( 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'wireless_lan_group', 'wireless_lans', name=_('Wireless') diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 66308677d7..f390be89bb 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -2616,7 +2616,9 @@ class InterfaceBulkEditView(generic.BulkEditView): table = tables.InterfaceTable form = forms.InterfaceBulkEditForm - def extra_object_field_operations(self, form, obj): + def post_save_operations(self, form, obj): + super().post_save_operations(form, obj) + # Add/remove tagged VLANs if obj.mode == InterfaceModeChoices.MODE_TAGGED: if form.cleaned_data.get('add_tagged_vlans', None): diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py index 33c8afe42e..4b2f1ae28c 100644 --- a/netbox/netbox/views/generic/bulk_views.py +++ b/netbox/netbox/views/generic/bulk_views.py @@ -541,12 +541,16 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView): def get_required_permission(self): return get_permission_for_model(self.queryset.model, 'change') - def extra_object_field_operations(self, form, obj): + def post_save_operations(self, form, obj): """ This method is called for each object in _update_objects. Override to perform additional object-level operations that are specific to a particular ModelForm. """ - pass + # Add/remove tags + if form.cleaned_data.get('add_tags', None): + obj.tags.add(*form.cleaned_data['add_tags']) + if form.cleaned_data.get('remove_tags', None): + obj.tags.remove(*form.cleaned_data['remove_tags']) def _update_objects(self, form, request): custom_fields = getattr(form, 'custom_fields', {}) @@ -619,13 +623,7 @@ def _update_objects(self, form, request): elif form.cleaned_data[name]: getattr(obj, name).set(form.cleaned_data[name]) - # Add/remove tags - if form.cleaned_data.get('add_tags', None): - obj.tags.add(*form.cleaned_data['add_tags']) - if form.cleaned_data.get('remove_tags', None): - obj.tags.remove(*form.cleaned_data['remove_tags']) - - self.extra_object_field_operations(form, obj) + self.post_save_operations(form, obj) # Rebuild the tree for MPTT models if issubclass(self.queryset.model, MPTTModel):