Closes #1944: Enable assigning VLANs to virtual machine interfaces

This commit is contained in:
Jeremy Stretch 2018-03-14 14:53:28 -04:00
parent ae6848b194
commit 8bd268d81c
7 changed files with 183 additions and 77 deletions

View File

@ -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, AnnotatedMultipleChoiceField, APISelect, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm,
BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ChainedFieldsMixin, ChainedModelChoiceField, BulkEditNullBooleanSelect, ChainedFieldsMixin, ChainedModelChoiceField, CommentField, ComponentForm,
ChainedModelMultipleChoiceField, CommentField, ComponentForm, ConfirmationForm, CSVChoiceField, ExpandableNameField, ConfirmationForm, CSVChoiceField, ExpandableNameField, FilterChoiceField, FilterTreeNodeMultipleChoiceField,
FilterChoiceField, FilterTreeNodeMultipleChoiceField, FlexibleModelChoiceField, Livesearch, SelectWithDisabled, FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SelectWithPK, SmallTextarea, SlugField,
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 />" 'mode': INTERFACE_MODE_HELP_TEXT,
"Tagged: One untagged VLAN and/or one or more tagged VLANs<br />"
"Tagged All: Implies all VLANs are available (w/optional untagged VLAN)"
} }
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 # Compile VLAN choices
device = self.instance.device vlan_choices = []
vlan_choices = [
('Global', [(vlan.pk, vlan) for vlan in VLAN.objects.filter(site=None).exclude(pk__in=assigned_vlans)]), # Add global VLANs
(device.site.name, [(vlan.pk, vlan) for vlan in VLAN.objects.filter(site=device.site, group=None).exclude(pk__in=assigned_vlans)]), global_vlans = VLAN.objects.filter(site=None, group=None).exclude(pk__in=assigned_vlans)
] vlan_choices.append((
for group in VLANGroup.objects.filter(site=device.site): 'Global', [(vlan.pk, vlan) for vlan in global_vlans])
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)] # 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):

View File

@ -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>

View File

@ -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"> {% include 'dcim/inc/interface_vlans_table.html' %}
<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>
<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

View File

@ -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 %}

View File

@ -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):

View File

@ -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

View File

@ -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):