Browse Source

Replaced tagged/untagged VLAN assignment widgets with a VLAN table; separate view for adding VLANs

Jeremy Stretch 7 years ago
parent
commit
7c043d9b4f
5 changed files with 129 additions and 179 deletions
  1. 61 170
      netbox/dcim/forms.py
  2. 10 0
      netbox/dcim/models.py
  3. 1 0
      netbox/dcim/urls.py
  4. 6 0
      netbox/dcim/views.py
  5. 51 9
      netbox/templates/dcim/interface_edit.html

+ 61 - 170
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
-        else:
-            self.fields['site'].queryset = Site.objects.none()
-            self.fields['site'].initial = None
+    def clean(self):
 
-        # 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,
-            }
+        super(InterfaceForm, self).clean()
 
-        self.fields['untagged_vlan'].queryset = VLAN.objects.filter(**filter_dict)
-        self.fields['tagged_vlans'].queryset = VLAN.objects.filter(**filter_dict)
+        # Validate VLAN assignments
+        untagged_vlan = self.cleaned_data['untagged_vlan']
+        tagged_vlans = self.cleaned_data['tagged_vlans']
 
-    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_ACCESS and tagged_vlans:
+            raise forms.ValidationError({
+                'mode': "An access interface cannot have tagged VLANs assigned."
+            })
 
-        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."
-            )
+        if untagged_vlan and untagged_vlan in tagged_vlans:
+            raise forms.ValidationError("VLAN {} cannot be both tagged and untagged.".format(untagged_vlan))
 
-        return self.cleaned_data['tagged_vlans']
+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 = []
 
-class InterfaceCreateForm(ComponentForm, ChainedFieldsMixin):
+    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.instance.untagged_vlan = self.cleaned_data['vlans'][0]
+
+        return super(InterfaceAssignVLANsForm, self).save(*args, **kwargs)
+
+
+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)

+ 10 - 0
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

+ 1 - 0
netbox/dcim/urls.py

@@ -185,6 +185,7 @@ urlpatterns = [
     url(r'^devices/(?P<pk>\d+)/interface-connections/add/$', views.InterfaceConnectionAddView.as_view(), name='interfaceconnection_add'),
     url(r'^interface-connections/(?P<pk>\d+)/delete/$', views.InterfaceConnectionDeleteView.as_view(), name='interfaceconnection_delete'),
     url(r'^interfaces/(?P<pk>\d+)/edit/$', views.InterfaceEditView.as_view(), name='interface_edit'),
+    url(r'^interfaces/(?P<pk>\d+)/assign-vlans/$', views.InterfaceAssignVLANsView.as_view(), name='interface_assign_vlans'),
     url(r'^interfaces/(?P<pk>\d+)/delete/$', views.InterfaceDeleteView.as_view(), name='interface_delete'),
     url(r'^interfaces/rename/$', views.InterfaceBulkRenameView.as_view(), name='interface_bulk_rename'),
 

+ 6 - 0
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

+ 51 - 9
netbox/templates/dcim/interface_edit.html

@@ -13,16 +13,58 @@
             {% render_field form.mtu %}
             {% render_field form.mgmt_only %}
             {% render_field form.description %}
-        </div>
-    </div>
-    <div class="panel panel-default">
-        <div class="panel-heading"><strong>802.1Q Encapsulation</strong></div>
-        <div class="panel-body">
             {% render_field form.mode %}
-            {% render_field form.site %}
-            {% render_field form.vlan_group %}
-            {% render_field form.untagged_vlan %}
-            {% render_field form.tagged_vlans %}
         </div>
     </div>
+    {% with interface=form.instance %}
+        {% if interface.mode %}
+            <div class="panel panel-default">
+                <div class="panel-heading"><strong>802.1Q VLANs</strong></div>
+                <table class="table panel-body">
+                    <tr>
+                        <th>VID</th>
+                        <th>Name</th>
+                        <th>Untagged</th>
+                        <th>Tagged</th>
+                    </tr>
+                    {% if interface.untagged_vlan %}
+                        <tr>
+                            <td>{{ interface.untagged_vlan.vid }}</td>
+                            <td>{{ interface.untagged_vlan.name }}</td>
+                            <td>
+                                <input type="radio" name="untagged_vlan" value="{{ interface.untagged_vlan.pk }}" checked="true" />
+                            </td>
+                            <td>
+                                <input type="checkbox" name="tagged_vlans" value="{{ interface.untagged_vlan.pk }}" />
+                            </td>
+                        </tr>
+                    {% endif %}
+                    {% for vlan in interface.tagged_vlans.all %}
+                        <tr>
+                            <td>{{ vlan.vid }}</td>
+                            <td>{{ vlan.name }}</td>
+                            <td>
+                                <input type="radio" name="untagged_vlan" value="{{ vlan.pk }}" />
+                            </td>
+                            <td>
+                                <input type="checkbox" name="tagged_vlans" value="{{ vlan.pk }}" checked="true" />
+                            </td>
+                        </tr>
+                    {% endfor %}
+                    {% if not interface.untagged_vlan and not interface.tagged_vlans.exists %}
+                        <tr>
+                            <td colspan="4">
+                                <span class="text-muted">No VLANs assigned</span>
+                            </td>
+                        </tr>
+                    {% endif %}
+                </table>
+                <div class="panel-footer text-right">
+                    <a href="{% url 'dcim:interface_assign_vlans' pk=interface.pk %}?return_url={% url 'dcim:interface_edit' pk=interface.pk %}" class="btn btn-primary btn-xs">
+                        <i class="glyphicon glyphicon-plus"></i> Add VLANs
+                    </a>
+                </div>
+            </div>
+        {% endif %}
+    {% endwith %}
 {% endblock %}