Browse Source

Closes #1944: Enable assigning VLANs to virtual machine interfaces

Jeremy Stretch 7 years ago
parent
commit
8bd268d81c

+ 43 - 20
netbox/dcim/forms.py

@@ -14,11 +14,10 @@ from ipam.models import IPAddress, VLAN, VLANGroup
 from tenancy.forms import TenancyForm
 from tenancy.forms import TenancyForm
 from tenancy.models import Tenant
 from tenancy.models import Tenant
 from utilities.forms import (
 from utilities.forms import (
-    AnnotatedMultipleChoiceField, APISelect, APISelectMultiple, add_blank_choice, ArrayFieldSelectMultiple,
-    BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ChainedFieldsMixin, ChainedModelChoiceField,
-    ChainedModelMultipleChoiceField, CommentField, ComponentForm, ConfirmationForm, CSVChoiceField, ExpandableNameField,
-    FilterChoiceField, FilterTreeNodeMultipleChoiceField, FlexibleModelChoiceField, Livesearch, SelectWithDisabled,
-    SelectWithPK, SmallTextarea, SlugField,
+    AnnotatedMultipleChoiceField, APISelect, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm,
+    BulkEditNullBooleanSelect, ChainedFieldsMixin, ChainedModelChoiceField, CommentField, ComponentForm,
+    ConfirmationForm, CSVChoiceField, ExpandableNameField, FilterChoiceField, FilterTreeNodeMultipleChoiceField,
+    FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SelectWithPK, SmallTextarea, SlugField,
 )
 )
 from virtualization.models import Cluster
 from virtualization.models import Cluster
 from .constants import (
 from .constants import (
@@ -37,6 +36,12 @@ from .models import (
 
 
 DEVICE_BY_PK_RE = '{\d+\}'
 DEVICE_BY_PK_RE = '{\d+\}'
 
 
+INTERFACE_MODE_HELP_TEXT = """
+Access: One untagged VLAN<br />
+Tagged: One untagged VLAN and/or one or more tagged VLANs<br />
+Tagged All: Implies all VLANs are available (w/optional untagged VLAN)
+"""
+
 
 
 def get_device_by_name_or_pk(name):
 def get_device_by_name_or_pk(name):
     """
     """
@@ -1657,7 +1662,7 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm):
     class Meta:
     class Meta:
         model = Interface
         model = Interface
         fields = [
         fields = [
-            'device', 'name', 'form_factor', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', 'description',
+            'device', 'name', 'form_factor', 'enabled', 'lag', 'mac_address', 'mtu', 'mgmt_only', 'description',
             'mode', 'untagged_vlan', 'tagged_vlans',
             'mode', 'untagged_vlan', 'tagged_vlans',
         ]
         ]
         widgets = {
         widgets = {
@@ -1667,9 +1672,7 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm):
             'mode': '802.1Q Mode',
             'mode': '802.1Q Mode',
         }
         }
         help_texts = {
         help_texts = {
-            'mode': "Access: One untagged VLAN<br />"
-                    "Tagged: One untagged VLAN and/or one or more tagged VLANs<br />"
-                    "Tagged All: Implies all VLANs are available (w/optional untagged VLAN)"
+            'mode': INTERFACE_MODE_HELP_TEXT,
         }
         }
 
 
     def __init__(self, *args, **kwargs):
     def __init__(self, *args, **kwargs):
@@ -1732,17 +1735,37 @@ class InterfaceAssignVLANsForm(BootstrapMixin, forms.ModelForm):
         if self.instance.untagged_vlan is not None:
         if self.instance.untagged_vlan is not None:
             assigned_vlans.append(self.instance.untagged_vlan.pk)
             assigned_vlans.append(self.instance.untagged_vlan.pk)
 
 
-        # Initialize VLAN choices
-        device = self.instance.device
-        vlan_choices = [
-            ('Global', [(vlan.pk, vlan) for vlan in VLAN.objects.filter(site=None).exclude(pk__in=assigned_vlans)]),
-            (device.site.name, [(vlan.pk, vlan) for vlan in VLAN.objects.filter(site=device.site, group=None).exclude(pk__in=assigned_vlans)]),
-        ]
-        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).exclude(pk__in=assigned_vlans)]
-            ))
+        # Compile VLAN choices
+        vlan_choices = []
+
+        # Add 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])
+            )
+
+        parent = self.instance.parent
+        if parent is not None:
+
+            # Add site VLANs
+            site_vlans = VLAN.objects.filter(site=parent.site, group=None).exclude(pk__in=assigned_vlans)
+            vlan_choices.append((parent.site.name, [(vlan.pk, vlan) for vlan in site_vlans]))
+
+            # Add grouped site VLANs
+            for group in VLANGroup.objects.filter(site=parent.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
         self.fields['vlans'].choices = vlan_choices
 
 
     def clean(self):
     def clean(self):

+ 55 - 0
netbox/templates/dcim/inc/interface_vlans_table.html

@@ -0,0 +1,55 @@
+<table class="table panel-body">
+    <tr>
+        <th>VID</th>
+        <th>Name</th>
+        <th>Untagged</th>
+        <th>Tagged</th>
+    </tr>
+    {% with tagged_vlans=obj.tagged_vlans.all %}
+        {% if obj.untagged_vlan and obj.untagged_vlan not in tagged_vlans %}
+            <tr>
+                <td>
+                    <a href="{{ obj.untagged_vlan.get_absolute_url }}">{{ obj.untagged_vlan.vid }}</a>
+                </td>
+                <td>{{ obj.untagged_vlan.name }}</td>
+                <td>
+                    <input type="radio" name="untagged_vlan" value="{{ obj.untagged_vlan.pk }}" checked="checked" />
+                </td>
+                <td>
+                    <input type="checkbox" name="tagged_vlans" value="{{ obj.untagged_vlan.pk }}" />
+                </td>
+            </tr>
+        {% endif %}
+        {% for vlan in tagged_vlans %}
+            <tr>
+                <td>
+                    <a href="{{ vlan.get_absolute_url }}">{{ vlan.vid }}</a>
+                </td>
+                <td>{{ vlan.name }}</td>
+                <td>
+                    <input type="radio" name="untagged_vlan" value="{{ vlan.pk }}"{% if vlan == obj.untagged_vlan %} checked="checked"{% endif %} />
+                </td>
+                <td>
+                    <input type="checkbox" name="tagged_vlans" value="{{ vlan.pk }}" checked="checked" />
+                </td>
+            </tr>
+        {% endfor %}
+        {% if not obj.untagged_vlan and not tagged_vlans %}
+            <tr>
+                <td colspan="4" class="text-muted text-center">
+                    No VLANs assigned
+                </td>
+            </tr>
+        {% else %}
+            <tr>
+                <td colspan="2"></td>
+                <td>
+                    <a href="#" id="clear_untagged_vlan" class="btn btn-warning btn-xs">Clear</a>
+                </td>
+                <td>
+                    <a href="#" id="clear_tagged_vlans" class="btn btn-warning btn-xs">Clear All</a>
+                </td>
+            </tr>
+        {% endif %}
+    {% endwith %}
+</table>

+ 1 - 53
netbox/templates/dcim/interface_edit.html

@@ -19,59 +19,7 @@
     {% if obj.mode %}
     {% if obj.mode %}
         <div class="panel panel-default" id="vlans_panel">
         <div class="panel panel-default" id="vlans_panel">
             <div class="panel-heading"><strong>802.1Q VLANs</strong></div>
             <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 obj.untagged_vlan %}
-                    <tr>
-                        <td>
-                            <a href="{{ obj.untagged_vlan.get_absolute_url }}">{{ obj.untagged_vlan.vid }}</a>
-                        </td>
-                        <td>{{ obj.untagged_vlan.name }}</td>
-                        <td>
-                            <input type="radio" name="untagged_vlan" value="{{ obj.untagged_vlan.pk }}" checked="true" />
-                        </td>
-                        <td>
-                            <input type="checkbox" name="tagged_vlans" value="{{ obj.untagged_vlan.pk }}" />
-                        </td>
-                    </tr>
-                {% endif %}
-                {% for vlan in obj.tagged_vlans.all %}
-                    <tr>
-                        <td>
-                            <a href="{{ vlan.get_absolute_url }}">{{ vlan.vid }}</a>
-                        </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 obj.untagged_vlan and not obj.tagged_vlans.exists %}
-                    <tr>
-                        <td colspan="4" class="text-muted text-center">
-                            No VLANs assigned
-                        </td>
-                    </tr>
-                {% else %}
-                    <tr>
-                        <td colspan="2"></td>
-                        <td>
-                            <a href="#" id="clear_untagged_vlan" class="btn btn-warning btn-xs">Clear</a>
-                        </td>
-                        <td>
-                            <a href="#" id="clear_tagged_vlans" class="btn btn-warning btn-xs">Clear All</a>
-                        </td>
-                    </tr>
-                {% endif %}
-            </table>
+            {% include 'dcim/inc/interface_vlans_table.html' %}
             <div class="panel-footer text-right">
             <div class="panel-footer text-right">
                 <a href="{% url 'dcim:interface_assign_vlans' pk=obj.pk %}?return_url={% url 'dcim:interface_edit' pk=obj.pk %}" class="btn btn-primary btn-xs{% if form.instance.mode == 100 and form.instance.untagged_vlan %} disabled{% endif %}">
                 <a href="{% url 'dcim:interface_assign_vlans' pk=obj.pk %}?return_url={% url 'dcim:interface_edit' pk=obj.pk %}" class="btn btn-primary btn-xs{% if form.instance.mode == 100 and form.instance.untagged_vlan %} disabled{% endif %}">
                     <i class="glyphicon glyphicon-plus"></i> Add VLANs
                     <i class="glyphicon glyphicon-plus"></i> Add VLANs

+ 53 - 0
netbox/templates/virtualization/interface_edit.html

@@ -0,0 +1,53 @@
+{% extends 'utilities/obj_edit.html' %}
+{% load form_helpers %}
+
+{% block form %}
+    <div class="panel panel-default">
+        <div class="panel-heading"><strong>Interface</strong></div>
+        <div class="panel-body">
+            {% render_field form.name %}
+            {% render_field form.enabled %}
+            {% render_field form.mac_address %}
+            {% render_field form.mtu %}
+            {% render_field form.description %}
+            {% render_field form.mode %}
+        </div>
+    </div>
+    {% if obj.mode %}
+        <div class="panel panel-default" id="vlans_panel">
+            <div class="panel-heading"><strong>802.1Q VLANs</strong></div>
+            {% include 'dcim/inc/interface_vlans_table.html' %}
+            <div class="panel-footer text-right">
+                <a href="{% url 'dcim:interface_assign_vlans' pk=obj.pk %}?return_url={% url 'virtualization:interface_edit' pk=obj.pk %}" class="btn btn-primary btn-xs{% if form.instance.mode == 100 and form.instance.untagged_vlan %} disabled{% endif %}">
+                    <i class="glyphicon glyphicon-plus"></i> Add VLANs
+                </a>
+            </div>
+        </div>
+    {% endif %}
+{% endblock %}
+
+{% block buttons %}
+    {% if obj.pk %}
+        <button type="submit" name="_update" class="btn btn-primary">Update</button>
+        <button type="submit" formaction="?return_url={% url 'virtualization:interface_edit' pk=obj.pk %}" class="btn btn-primary">Update and Continue Editing</button>
+    {% else %}
+        <button type="submit" name="_create" class="btn btn-primary">Create</button>
+        <button type="submit" name="_addanother" class="btn btn-primary">Create and Add Another</button>
+    {% endif %}
+    <a href="{{ return_url }}" class="btn btn-default">Cancel</a>
+{% endblock %}
+
+{% block javascript %}
+    <script type="text/javascript">
+        $(document).ready(function() {
+            $('#clear_untagged_vlan').click(function () {
+                $('input[name="untagged_vlan"]').prop("checked", false);
+                return false;
+            });
+            $('#clear_tagged_vlans').click(function () {
+                $('input[name="tagged_vlans"]').prop("checked", false);
+                return false;
+            });
+        });
+    </script>
+{% endblock %}

+ 29 - 2
netbox/virtualization/forms.py

@@ -5,7 +5,8 @@ from django.core.exceptions import ValidationError
 from django.db.models import Count
 from django.db.models import Count
 from mptt.forms import TreeNodeChoiceField
 from mptt.forms import TreeNodeChoiceField
 
 
-from dcim.constants import IFACE_FF_VIRTUAL
+from dcim.constants import IFACE_FF_VIRTUAL, IFACE_MODE_ACCESS, IFACE_MODE_TAGGED_ALL
+from dcim.forms import INTERFACE_MODE_HELP_TEXT
 from dcim.formfields import MACAddressFormField
 from dcim.formfields import MACAddressFormField
 from dcim.models import Device, DeviceRole, Interface, Platform, Rack, Region, Site
 from dcim.models import Device, DeviceRole, Interface, Platform, Rack, Region, Site
 from extras.forms import CustomFieldBulkEditForm, CustomFieldForm, CustomFieldFilterForm
 from extras.forms import CustomFieldBulkEditForm, CustomFieldForm, CustomFieldFilterForm
@@ -414,11 +415,37 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm):
 
 
     class Meta:
     class Meta:
         model = Interface
         model = Interface
-        fields = ['virtual_machine', 'name', 'form_factor', 'enabled', 'mac_address', 'mtu', 'description']
+        fields = [
+            'virtual_machine', 'name', 'form_factor', 'enabled', 'mac_address', 'mtu', 'description', 'mode',
+            'untagged_vlan', 'tagged_vlans',
+        ]
         widgets = {
         widgets = {
             'virtual_machine': forms.HiddenInput(),
             'virtual_machine': forms.HiddenInput(),
             'form_factor': forms.HiddenInput(),
             'form_factor': forms.HiddenInput(),
         }
         }
+        labels = {
+            'mode': '802.1Q Mode',
+        }
+        help_texts = {
+            'mode': INTERFACE_MODE_HELP_TEXT,
+        }
+
+    def clean(self):
+
+        super(InterfaceForm, self).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 InterfaceCreateForm(ComponentForm):
 class InterfaceCreateForm(ComponentForm):

+ 1 - 2
netbox/virtualization/models.py

@@ -283,7 +283,6 @@ class VirtualMachine(CreatedUpdatedModel, CustomFieldModel):
         else:
         else:
             return None
             return None
 
 
+    @property
     def site(self):
     def site(self):
-        # used when a child compent (eg Interface) needs to know its parent's site but
-        # the parent could be either a device or a virtual machine
         return self.cluster.site
         return self.cluster.site

+ 1 - 0
netbox/virtualization/views.py

@@ -329,6 +329,7 @@ class InterfaceEditView(PermissionRequiredMixin, ObjectEditView):
     permission_required = 'dcim.change_interface'
     permission_required = 'dcim.change_interface'
     model = Interface
     model = Interface
     model_form = forms.InterfaceForm
     model_form = forms.InterfaceForm
+    template_name = 'virtualization/interface_edit.html'
 
 
 
 
 class InterfaceDeleteView(PermissionRequiredMixin, ObjectDeleteView):
 class InterfaceDeleteView(PermissionRequiredMixin, ObjectDeleteView):