From 9c6dbd73376d0596d906686c5f3f287f28137d5f Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Fri, 6 Sep 2019 12:45:37 -0500 Subject: [PATCH] Add in in-line vlan editing and Bulk vlan editing (#3350) * Fixes #3341 - Added in-line vlan editing * Fixes #2160 - Added bulk vlan editing Inconsequential behaviour changes: * APISelect can now take "full=True" to return a non-brief set * Select2 will no group by "group & site, group, site, global" if full=True is set in APISelect --- netbox/dcim/forms.py | 186 +++++++++------------- netbox/dcim/urls.py | 1 - netbox/dcim/views.py | 6 - netbox/project-static/js/forms.js | 73 +++++++-- netbox/templates/dcim/interface_edit.html | 34 +--- netbox/utilities/forms.py | 3 + netbox/utilities/views.py | 9 +- 7 files changed, 155 insertions(+), 157 deletions(-) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index ed2f40e35..2a37de4fc 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -56,6 +56,25 @@ def get_device_by_name_or_pk(name): return device +class InterfaceCommonForm: + def clean(self): + + super().clean() + + # Validate VLAN assignments + tagged_vlans = self.cleaned_data['tagged_vlans'] + + # Untagged interfaces cannot be assigned tagged VLANs + if self.cleaned_data['mode'] == IFACE_MODE_ACCESS and tagged_vlans: + raise forms.ValidationError({ + 'mode': "An access interface cannot have tagged VLANs assigned." + }) + + # Remove all tagged VLAN assignments from "tagged all" interfaces + elif self.cleaned_data['mode'] == IFACE_MODE_TAGGED_ALL: + self.cleaned_data['tagged_vlans'] = [] + + class BulkRenameForm(forms.Form): """ An extendable form to be used for renaming device components in bulk. @@ -2110,7 +2129,26 @@ class PowerOutletBulkDisconnectForm(ConfirmationForm): # Interfaces # -class InterfaceForm(BootstrapMixin, forms.ModelForm): +class InterfaceForm(InterfaceCommonForm, BootstrapMixin, forms.ModelForm): + untagged_vlan = forms.ModelChoiceField( + queryset=VLAN.objects.all(), + required=False, + widget=APISelect( + api_url="/api/ipam/vlans/", + display_field='display_name', + full=True + ) + ) + tagged_vlans = forms.ModelMultipleChoiceField( + queryset=VLAN.objects.all(), + required=False, + widget=APISelectMultiple( + api_url="/api/ipam/vlans/", + display_field='display_name', + full=True + ) + ) + tags = TagField( required=False ) @@ -2149,112 +2187,8 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm): device__in=[self.instance.device, self.instance.device.get_vc_master()], type=IFACE_TYPE_LAG ) - def clean(self): - super().clean() - - # Validate VLAN assignments - tagged_vlans = self.cleaned_data['tagged_vlans'] - - # Untagged interfaces cannot be assigned tagged VLANs - if self.cleaned_data['mode'] == IFACE_MODE_ACCESS and tagged_vlans: - raise forms.ValidationError({ - 'mode': "An access interface cannot have tagged VLANs assigned." - }) - - # Remove all tagged VLAN assignments from "tagged all" interfaces - elif self.cleaned_data['mode'] == IFACE_MODE_TAGGED_ALL: - self.cleaned_data['tagged_vlans'] = [] - - -class InterfaceAssignVLANsForm(BootstrapMixin, forms.ModelForm): - vlans = forms.MultipleChoiceField( - choices=[], - label='VLANs', - widget=StaticSelect2Multiple( - attrs={ - 'size': 20, - } - ) - ) - tagged = forms.BooleanField( - required=False, - initial=True - ) - - class Meta: - model = Interface - fields = [] - - def __init__(self, *args, **kwargs): - - super().__init__(*args, **kwargs) - - if self.instance.mode == IFACE_MODE_ACCESS: - self.initial['tagged'] = False - - # Find all VLANs already assigned to the interface for exclusion from the list - assigned_vlans = [v.pk for v in self.instance.tagged_vlans.all()] - if self.instance.untagged_vlan is not None: - assigned_vlans.append(self.instance.untagged_vlan.pk) - - # Compile VLAN choices - vlan_choices = [] - - # Add non-grouped global VLANs - global_vlans = VLAN.objects.filter(site=None, group=None).exclude(pk__in=assigned_vlans) - vlan_choices.append( - ('Global', [(vlan.pk, vlan) for vlan in global_vlans]) - ) - - # Add grouped global VLANs - for group in VLANGroup.objects.filter(site=None): - global_group_vlans = VLAN.objects.filter(group=group).exclude(pk__in=assigned_vlans) - vlan_choices.append( - (group.name, [(vlan.pk, vlan) for vlan in global_group_vlans]) - ) - - site = getattr(self.instance.parent, 'site', None) - if site is not None: - - # Add non-grouped site VLANs - site_vlans = VLAN.objects.filter(site=site, group=None).exclude(pk__in=assigned_vlans) - vlan_choices.append((site.name, [(vlan.pk, vlan) for vlan in site_vlans])) - - # Add grouped site VLANs - for group in VLANGroup.objects.filter(site=site): - site_group_vlans = VLAN.objects.filter(group=group).exclude(pk__in=assigned_vlans) - vlan_choices.append(( - '{} / {}'.format(group.site.name, group.name), - [(vlan.pk, vlan) for vlan in site_group_vlans] - )) - - self.fields['vlans'].choices = vlan_choices - - def clean(self): - - super().clean() - - # Only untagged VLANs permitted on an access interface - if self.instance.mode == IFACE_MODE_ACCESS and len(self.cleaned_data['vlans']) > 1: - raise forms.ValidationError("Only one VLAN may be assigned to an access interface.") - - # 'tagged' is required if more than one VLAN is selected - if not self.cleaned_data['tagged'] and len(self.cleaned_data['vlans']) > 1: - raise forms.ValidationError("Only one untagged VLAN may be selected.") - - def save(self, *args, **kwargs): - - if self.cleaned_data['tagged']: - for vlan in self.cleaned_data['vlans']: - self.instance.tagged_vlans.add(vlan) - else: - self.instance.untagged_vlan_id = self.cleaned_data['vlans'][0] - - return super().save(*args, **kwargs) - - -class InterfaceCreateForm(ComponentForm, forms.Form): +class InterfaceCreateForm(InterfaceCommonForm, ComponentForm, forms.Form): name_pattern = ExpandableNameField( label='Name' ) @@ -2298,6 +2232,24 @@ class InterfaceCreateForm(ComponentForm, forms.Form): tags = TagField( required=False ) + untagged_vlan = forms.ModelChoiceField( + queryset=VLAN.objects.all(), + required=False, + widget=APISelect( + api_url="/api/ipam/vlans/", + display_field='display_name', + full=True + ) + ) + tagged_vlans = forms.ModelMultipleChoiceField( + queryset=VLAN.objects.all(), + required=False, + widget=APISelectMultiple( + api_url="/api/ipam/vlans/", + display_field='display_name', + full=True + ) + ) def __init__(self, *args, **kwargs): @@ -2316,7 +2268,7 @@ class InterfaceCreateForm(ComponentForm, forms.Form): self.fields['lag'].queryset = Interface.objects.none() -class InterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm): +class InterfaceBulkEditForm(InterfaceCommonForm, BootstrapMixin, AddRemoveTagsForm, BulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=Interface.objects.all(), widget=forms.MultipleHiddenInput() @@ -2360,10 +2312,28 @@ class InterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm): required=False, widget=StaticSelect2() ) + untagged_vlan = forms.ModelChoiceField( + queryset=VLAN.objects.all(), + required=False, + widget=APISelect( + api_url="/api/ipam/vlans/", + display_field='display_name', + full=True + ) + ) + tagged_vlans = forms.ModelMultipleChoiceField( + queryset=VLAN.objects.all(), + required=False, + widget=APISelectMultiple( + api_url="/api/ipam/vlans/", + display_field='display_name', + full=True + ) + ) class Meta: nullable_fields = [ - 'lag', 'mac_address', 'mtu', 'description', 'mode', + 'lag', 'mac_address', 'mtu', 'description', 'mode', 'untagged_vlan', 'tagged_vlans' ] def __init__(self, *args, **kwargs): diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index ae1f05757..43316baf4 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -209,7 +209,6 @@ urlpatterns = [ path(r'interfaces//connect//', views.CableCreateView.as_view(), name='interface_connect', kwargs={'termination_a_type': Interface}), path(r'interfaces//', views.InterfaceView.as_view(), name='interface'), path(r'interfaces//edit/', views.InterfaceEditView.as_view(), name='interface_edit'), - path(r'interfaces//assign-vlans/', views.InterfaceAssignVLANsView.as_view(), name='interface_assign_vlans'), path(r'interfaces//delete/', views.InterfaceDeleteView.as_view(), name='interface_delete'), path(r'interfaces//changelog/', ObjectChangeLogView.as_view(), name='interface_changelog', kwargs={'model': Interface}), path(r'interfaces//trace/', views.CableTraceView.as_view(), name='interface_trace', kwargs={'model': Interface}), diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index fe98c93f3..f953f95c2 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1348,12 +1348,6 @@ class InterfaceEditView(PermissionRequiredMixin, ObjectEditView): template_name = 'dcim/interface_edit.html' -class InterfaceAssignVLANsView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'dcim.change_interface' - model = Interface - model_form = forms.InterfaceAssignVLANsForm - - class InterfaceDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'dcim.delete_interface' model = Interface diff --git a/netbox/project-static/js/forms.js b/netbox/project-static/js/forms.js index b1a08c7f4..2469c0f8d 100644 --- a/netbox/project-static/js/forms.js +++ b/netbox/project-static/js/forms.js @@ -143,11 +143,13 @@ $(document).ready(function() { // Base query params var parameters = { q: params.term, - brief: 1, limit: 50, offset: offset, }; + // Allow for controlling the brief setting from within APISelect + parameters.brief = ( $('#id_untagged_vlan').is('[data-full]') ? undefined : true ); + // filter-for fields from a chain var attr_name = "data-filter-for-" + $(element).attr("name"); var form = $(element).closest('form'); @@ -194,18 +196,41 @@ $(document).ready(function() { processResults: function (data) { var element = this.$element[0]; - // Clear any disabled options $(element).children('option').attr('disabled', false); - var results = $.map(data.results, function (obj) { - obj.text = obj[element.getAttribute('display-field')] || obj.name; - obj.id = obj[element.getAttribute('value-field')] || obj.id; + var results = data.results; - if(element.getAttribute('disabled-indicator') && obj[element.getAttribute('disabled-indicator')]) { + results = results.reduce((results,record) => { + record.text = record[element.getAttribute('display-field')] || record.name; + record.id = record[element.getAttribute('value-field')] || record.id; + if(element.getAttribute('disabled-indicator') && record[element.getAttribute('disabled-indicator')]) { // The disabled-indicator equated to true, so we disable this option - obj.disabled = true; + record.disabled = true; } - return obj; - }); + + if( record.group !== undefined && record.group !== null && record.site !== undefined && record.site !== null ) { + results[record.site.name + ":" + record.group.name] = results[record.site.name + ":" + record.group.name] || { text: record.site.name + " / " + record.group.name, children: [] } + results[record.site.name + ":" + record.group.name].children.push(record); + } + else if( record.group !== undefined && record.group !== null ) { + results[record.group.name] = results[record.group.name] || { text: record.group.name, children: [] } + results[record.group.name].children.push(record); + } + else if( record.site !== undefined && record.site !== null ) { + results[record.site.name] = results[record.site.name] || { text: record.site.name, children: [] } + results[record.site.name].children.push(record); + } + else if ( (record.group !== undefined || record.group == null) && (record.site !== undefined || record.site === null) ) { + results['global'] = results['global'] || { text: 'Global', children: [] } + results['global'].children.push(record); + } + else { + results[record.id] = record + } + + return results; + },Object.create(null)); + + results = Object.values(results); // Handle the null option, but only add it once if (element.getAttribute('data-null-option') && data.previous === null) { @@ -300,4 +325,34 @@ $(document).ready(function() { $('#id_tags').append(option).trigger('change'); } }); + + if( $('select#id_mode').length > 0 ) { + $('select#id_mode').on('change', function () { + if ($(this).val() == '') { + $('select#id_untagged_vlan').val(); + $('select#id_untagged_vlan').trigger('change'); + $('select#id_tagged_vlans').val([]); + $('select#id_tagged_vlans').trigger('change'); + $('select#id_untagged_vlan').parent().parent().hide(); + $('select#id_tagged_vlans').parent().parent().hide(); + } + else if ($(this).val() == 100) { + $('select#id_tagged_vlans').val([]); + $('select#id_tagged_vlans').trigger('change'); + $('select#id_untagged_vlan').parent().parent().show(); + $('select#id_tagged_vlans').parent().parent().hide(); + } + else if ($(this).val() == 200) { + $('select#id_untagged_vlan').parent().parent().show(); + $('select#id_tagged_vlans').parent().parent().show(); + } + else if ($(this).val() == 300) { + $('select#id_tagged_vlans').val([]); + $('select#id_tagged_vlans').trigger('change'); + $('select#id_untagged_vlan').parent().parent().show(); + $('select#id_tagged_vlans').parent().parent().hide(); + } + }); + $('select#id_mode').trigger('change'); + } }); diff --git a/netbox/templates/dcim/interface_edit.html b/netbox/templates/dcim/interface_edit.html index fedb1b61a..a80b7c592 100644 --- a/netbox/templates/dcim/interface_edit.html +++ b/netbox/templates/dcim/interface_edit.html @@ -14,6 +14,8 @@ {% render_field form.mgmt_only %} {% render_field form.description %} {% render_field form.mode %} + {% render_field form.untagged_vlan %} + {% render_field form.tagged_vlans %}
@@ -22,21 +24,6 @@ {% render_field form.tags %}
-
-
802.1Q VLANs
- {% if obj.mode %} - {% include 'dcim/inc/interface_vlans_table.html' %} - - {% else %} -
-

802.1Q mode not set

-
- {% endif %} -
{% endblock %} {% block buttons %} @@ -48,19 +35,4 @@ {% endif %} Cancel -{% endblock %} - -{% block javascript %} - -{% endblock %} +{% endblock %} \ No newline at end of file diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index 1d4671bf6..aeba4d53d 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -298,6 +298,7 @@ class APISelect(SelectWithDisabled): conditional_query_params=None, additional_query_params=None, null_option=False, + full=False, *args, **kwargs ): @@ -306,6 +307,8 @@ class APISelect(SelectWithDisabled): self.attrs['class'] = 'netbox-select2-api' self.attrs['data-url'] = '/{}{}'.format(settings.BASE_PATH, api_url.lstrip('/')) # Inject BASE_PATH + if full: + self.attrs['data-full'] = full if display_field: self.attrs['display-field'] = display_field if value_field: diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index bbc58f134..1aa358fba 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -7,6 +7,7 @@ from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.db import transaction, IntegrityError from django.db.models import Count, ProtectedError +from django.db.models.query import QuerySet from django.forms import CharField, Form, ModelMultipleChoiceField, MultipleHiddenInput, Textarea from django.http import HttpResponse, HttpResponseServerError from django.shortcuts import get_object_or_404, redirect, render @@ -530,9 +531,13 @@ class BulkEditView(GetReturnURLMixin, View): # Update standard fields. If a field is listed in _nullify, delete its value. for name in standard_fields: - if name in form.nullable_fields and name in nullified_fields: + if name in form.nullable_fields and name in nullified_fields and isinstance(form.cleaned_data[name], QuerySet): + getattr(obj, name).set([]) + elif name in form.nullable_fields and name in nullified_fields: setattr(obj, name, '' if isinstance(form.fields[name], CharField) else None) - elif form.cleaned_data[name] not in (None, ''): + elif isinstance(form.cleaned_data[name], QuerySet) and form.cleaned_data[name]: + getattr(obj, name).set(form.cleaned_data[name]) + elif form.cleaned_data[name] not in (None, '') and not isinstance(form.cleaned_data[name], QuerySet): setattr(obj, name, form.cleaned_data[name]) obj.full_clean() obj.save()