diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index d5089cc53..0a62b460e 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -1652,63 +1652,23 @@ class PowerOutletBulkDisconnectForm(ConfirmationForm): # Interfaces # -class InterfaceForm(BootstrapMixin, forms.ModelForm, ChainedFieldsMixin): - site = forms.ModelChoiceField( - queryset=Site.objects.all(), - required=False, - label='VLAN site', - widget=forms.Select( - attrs={'filter-for': 'vlan_group', 'nullable': 'true'}, - ) - ) - vlan_group = ChainedModelChoiceField( - queryset=VLANGroup.objects.all(), - chains=( - ('site', 'site'), - ), - required=False, - label='VLAN group', - widget=APISelect( - attrs={'filter-for': 'untagged_vlan tagged_vlans', 'nullable': 'true'}, - api_url='/api/ipam/vlan-groups/?site_id={{site}}', - ) - ) - untagged_vlan = ChainedModelChoiceField( - queryset=VLAN.objects.all(), - chains=( - ('site', 'site'), - ('group', 'vlan_group'), - ), - required=False, - label='Untagged VLAN', - widget=APISelect( - api_url='/api/ipam/vlans/?site_id={{site}}&group_id={{vlan_group}}', - display_field='display_name' - ) - ) - tagged_vlans = ChainedModelMultipleChoiceField( - queryset=VLAN.objects.all(), - chains=( - ('site', 'site'), - ('group', 'vlan_group'), - ), - required=False, - label='Tagged VLANs', - widget=APISelectMultiple( - api_url='/api/ipam/vlans/?site_id={{site}}&group_id={{vlan_group}}', - display_field='display_name' - ) - ) +class InterfaceForm(BootstrapMixin, forms.ModelForm): class Meta: model = Interface fields = [ 'device', 'name', 'form_factor', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', 'description', - 'mode', 'site', 'vlan_group', 'untagged_vlan', 'tagged_vlans', + 'mode', 'untagged_vlan', 'tagged_vlans', ] widgets = { 'device': forms.HiddenInput(), } + labels = { + 'mode': '802.1Q Mode', + } + help_texts = { + 'mode': "Nullifying the mode will clear any associated VLANs." + } def __init__(self, *args, **kwargs): super(InterfaceForm, self).__init__(*args, **kwargs) @@ -1725,58 +1685,66 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm, ChainedFieldsMixin): device__in=[self.instance.device, self.instance.device.get_vc_master()], form_factor=IFACE_FF_LAG ) - # Limit the queryset for the site to only include the interface's device's site - if device and device.site: - self.fields['site'].queryset = Site.objects.filter(pk=device.site.id) - self.fields['site'].initial = None + def clean(self): + + super(InterfaceForm, self).clean() + + # Validate VLAN assignments + untagged_vlan = self.cleaned_data['untagged_vlan'] + tagged_vlans = self.cleaned_data['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." + }) + + if untagged_vlan and untagged_vlan in tagged_vlans: + raise forms.ValidationError("VLAN {} cannot be both tagged and untagged.".format(untagged_vlan)) + +class InterfaceAssignVLANsForm(BootstrapMixin, forms.ModelForm): + vlans = forms.MultipleChoiceField( + choices=[], + label='VLANs', + widget=forms.SelectMultiple(attrs={'size': 20}) + ) + tagged = forms.BooleanField( + required=False, + initial=True + ) + + class Meta: + model = Interface + fields = [] + + def __init__(self, *args, **kwargs): + + super(InterfaceAssignVLANsForm, self).__init__(*args, **kwargs) + + # Initialize VLAN choices + device = self.instance.device + vlan_choices = [ + ('Global', [(vlan.pk, vlan) for vlan in VLAN.objects.filter(site=None)]), + (device.site.name, [(vlan.pk, vlan) for vlan in VLAN.objects.filter(site=device.site, group=None)]), + ] + for group in VLANGroup.objects.filter(site=device.site): + vlan_choices.append(( + '{} / {}'.format(group.site.name, group.name), + [(vlan.pk, vlan) for vlan in VLAN.objects.filter(group=group)] + )) + self.fields['vlans'].choices = vlan_choices + + 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.fields['site'].queryset = Site.objects.none() - self.fields['site'].initial = None + self.instance.untagged_vlan = self.cleaned_data['vlans'][0] - # Limit the initial vlan choices - if self.is_bound and self.data.get('vlan_group') and self.data.get('site'): - filter_dict = { - 'group_id': self.data.get('vlan_group'), - 'site_id': self.data.get('site'), - } - elif self.initial.get('untagged_vlan'): - filter_dict = { - 'group_id': self.instance.untagged_vlan.group, - 'site_id': self.instance.untagged_vlan.site, - } - elif self.initial.get('tagged_vlans'): - filter_dict = { - 'group_id': self.instance.tagged_vlans.first().group, - 'site_id': self.instance.tagged_vlans.first().site, - } - else: - filter_dict = { - 'group_id': None, - 'site_id': None, - } - - self.fields['untagged_vlan'].queryset = VLAN.objects.filter(**filter_dict) - self.fields['tagged_vlans'].queryset = VLAN.objects.filter(**filter_dict) - - def clean_tagged_vlans(self): - """ - Because tagged_vlans is a many-to-many relationship, validation must be done in the form - """ - if self.cleaned_data['mode'] == IFACE_MODE_ACCESS and self.cleaned_data['tagged_vlans']: - raise forms.ValidationError( - "An Access interface cannot have tagged VLANs." - ) - - if self.cleaned_data['mode'] == IFACE_MODE_TAGGED_ALL and self.cleaned_data['tagged_vlans']: - raise forms.ValidationError( - "Interface mode Tagged All implies all VLANs are tagged. " - "Do not select any tagged VLANs." - ) - - return self.cleaned_data['tagged_vlans'] + return super(InterfaceAssignVLANsForm, self).save(*args, **kwargs) -class InterfaceCreateForm(ComponentForm, ChainedFieldsMixin): +class InterfaceCreateForm(ComponentForm, forms.ModelForm): name_pattern = ExpandableNameField(label='Name') form_factor = forms.ChoiceField(choices=IFACE_FF_CHOICES) enabled = forms.BooleanField(required=False) @@ -1790,50 +1758,6 @@ class InterfaceCreateForm(ComponentForm, ChainedFieldsMixin): ) description = forms.CharField(max_length=100, required=False) mode = forms.ChoiceField(choices=add_blank_choice(IFACE_MODE_CHOICES), required=False) - site = forms.ModelChoiceField( - queryset=Site.objects.all(), - required=False, - label='VLAN Site', - widget=forms.Select( - attrs={'filter-for': 'vlan_group', 'nullable': 'true'}, - ) - ) - vlan_group = ChainedModelChoiceField( - queryset=VLANGroup.objects.all(), - chains=( - ('site', 'site'), - ), - required=False, - label='VLAN group', - widget=APISelect( - attrs={'filter-for': 'untagged_vlan tagged_vlans', 'nullable': 'true'}, - api_url='/api/ipam/vlan-groups/?site_id={{site}}', - ) - ) - untagged_vlan = ChainedModelChoiceField( - queryset=VLAN.objects.all(), - chains=( - ('site', 'site'), - ('group', 'vlan_group'), - ), - required=False, - label='Untagged VLAN', - widget=APISelect( - api_url='/api/ipam/vlans/?site_id={{site}}&group_id={{vlan_group}}', - ) - ) - tagged_vlans = ChainedModelMultipleChoiceField( - queryset=VLAN.objects.all(), - chains=( - ('site', 'site'), - ('group', 'vlan_group'), - ), - required=False, - label='Tagged VLANs', - widget=APISelectMultiple( - api_url='/api/ipam/vlans/?site_id={{site}}&group_id={{vlan_group}}', - ) - ) def __init__(self, *args, **kwargs): @@ -1851,39 +1775,6 @@ class InterfaceCreateForm(ComponentForm, ChainedFieldsMixin): else: self.fields['lag'].queryset = Interface.objects.none() - # Limit the queryset for the site to only include the interface's device's site - if self.parent is not None and self.parent.site: - self.fields['site'].queryset = Site.objects.filter(pk=self.parent.site.id) - self.fields['site'].initial = None - else: - self.fields['site'].queryset = Site.objects.none() - self.fields['site'].initial = None - - # Limit the initial vlan choices - if self.is_bound and self.data.get('vlan_group') and self.data.get('site'): - filter_dict = { - 'group_id': self.data.get('vlan_group'), - 'site_id': self.data.get('site'), - } - elif self.initial.get('untagged_vlan'): - filter_dict = { - 'group_id': self.untagged_vlan.group, - 'site_id': self.untagged_vlan.site, - } - elif self.initial.get('tagged_vlans'): - filter_dict = { - 'group_id': self.tagged_vlans.first().group, - 'site_id': self.tagged_vlans.first().site, - } - else: - filter_dict = { - 'group_id': None, - 'site_id': None, - } - - self.fields['untagged_vlan'].queryset = VLAN.objects.filter(**filter_dict) - self.fields['tagged_vlans'].queryset = VLAN.objects.filter(**filter_dict) - class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm, ChainedFieldsMixin): pk = forms.ModelMultipleChoiceField(queryset=Interface.objects.all(), widget=forms.MultipleHiddenInput) diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index cfae0d6d2..3d8e41068 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -1455,6 +1455,16 @@ class Interface(models.Model): "device/VM, or it must be global".format(self.untagged_vlan) }) + def save(self, *args, **kwargs): + + if self.mode is None: + self.untagged_vlan = None + self.tagged_vlans = [] + elif self.mode is IFACE_MODE_ACCESS: + self.tagged_vlans = [] + + return super(Interface, self).save(*args, **kwargs) + @property def parent(self): return self.device or self.virtual_machine diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index e7e1e41df..5682bd8e7 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -185,6 +185,7 @@ urlpatterns = [ url(r'^devices/(?P\d+)/interface-connections/add/$', views.InterfaceConnectionAddView.as_view(), name='interfaceconnection_add'), url(r'^interface-connections/(?P\d+)/delete/$', views.InterfaceConnectionDeleteView.as_view(), name='interfaceconnection_delete'), url(r'^interfaces/(?P\d+)/edit/$', views.InterfaceEditView.as_view(), name='interface_edit'), + url(r'^interfaces/(?P\d+)/assign-vlans/$', views.InterfaceAssignVLANsView.as_view(), name='interface_assign_vlans'), url(r'^interfaces/(?P\d+)/delete/$', views.InterfaceDeleteView.as_view(), name='interface_delete'), url(r'^interfaces/rename/$', views.InterfaceBulkRenameView.as_view(), name='interface_bulk_rename'), diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 2842cd549..a4449c9b0 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1645,6 +1645,12 @@ 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/templates/dcim/interface_edit.html b/netbox/templates/dcim/interface_edit.html index 648d73151..071276e38 100644 --- a/netbox/templates/dcim/interface_edit.html +++ b/netbox/templates/dcim/interface_edit.html @@ -13,16 +13,58 @@ {% render_field form.mtu %} {% render_field form.mgmt_only %} {% render_field form.description %} - - -
-
802.1Q Encapsulation
-
{% render_field form.mode %} - {% render_field form.site %} - {% render_field form.vlan_group %} - {% render_field form.untagged_vlan %} - {% render_field form.tagged_vlans %}
+ {% with interface=form.instance %} + {% if interface.mode %} +
+
802.1Q VLANs
+ + + + + + + + {% if interface.untagged_vlan %} + + + + + + + {% endif %} + {% for vlan in interface.tagged_vlans.all %} + + + + + + + {% endfor %} + {% if not interface.untagged_vlan and not interface.tagged_vlans.exists %} + + + + {% endif %} +
VIDNameUntaggedTagged
{{ interface.untagged_vlan.vid }}{{ interface.untagged_vlan.name }} + + + +
{{ vlan.vid }}{{ vlan.name }} + + + +
+ No VLANs assigned +
+ +
+ {% endif %} + {% endwith %} {% endblock %}