Parcourir la source

Merge branch '150-interface-vlans' into develop-2.3

Jeremy Stretch il y a 7 ans
Parent
commit
ba42ad2115

+ 46 - 4
netbox/dcim/api/serializers.py

@@ -7,8 +7,8 @@ from rest_framework.validators import UniqueTogetherValidator
 
 from circuits.models import Circuit, CircuitTermination
 from dcim.constants import (
-    CONNECTION_STATUS_CHOICES, IFACE_FF_CHOICES, IFACE_ORDERING_CHOICES, RACK_FACE_CHOICES, RACK_TYPE_CHOICES,
-    RACK_WIDTH_CHOICES, STATUS_CHOICES, SUBDEVICE_ROLE_CHOICES,
+    CONNECTION_STATUS_CHOICES, IFACE_FF_CHOICES, IFACE_MODE_CHOICES, IFACE_ORDERING_CHOICES, RACK_FACE_CHOICES,
+    RACK_TYPE_CHOICES, RACK_WIDTH_CHOICES, STATUS_CHOICES, SUBDEVICE_ROLE_CHOICES,
 )
 from dcim.models import (
     ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
@@ -17,7 +17,7 @@ from dcim.models import (
     RackReservation, RackRole, Region, Site,
 )
 from extras.api.customfields import CustomFieldModelSerializer
-from ipam.models import IPAddress
+from ipam.models import IPAddress, VLAN
 from tenancy.api.serializers import NestedTenantSerializer
 from utilities.api import ChoiceFieldSerializer, ValidatedModelSerializer
 from virtualization.models import Cluster
@@ -628,6 +628,15 @@ class InterfaceCircuitTerminationSerializer(serializers.ModelSerializer):
         ]
 
 
+# Cannot import ipam.api.NestedVLANSerializer due to circular dependency
+class InterfaceVLANSerializer(serializers.ModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlan-detail')
+
+    class Meta:
+        model = VLAN
+        fields = ['id', 'url', 'vid', 'name', 'display_name']
+
+
 class InterfaceSerializer(serializers.ModelSerializer):
     device = NestedDeviceSerializer()
     form_factor = ChoiceFieldSerializer(choices=IFACE_FF_CHOICES)
@@ -635,12 +644,15 @@ class InterfaceSerializer(serializers.ModelSerializer):
     is_connected = serializers.SerializerMethodField(read_only=True)
     interface_connection = serializers.SerializerMethodField(read_only=True)
     circuit_termination = InterfaceCircuitTerminationSerializer()
+    untagged_vlan = InterfaceVLANSerializer()
+    mode = ChoiceFieldSerializer(choices=IFACE_MODE_CHOICES)
+    tagged_vlans = InterfaceVLANSerializer(many=True)
 
     class Meta:
         model = Interface
         fields = [
             'id', 'device', 'name', 'form_factor', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', 'description',
-            'is_connected', 'interface_connection', 'circuit_termination',
+            'is_connected', 'interface_connection', 'circuit_termination', 'mode', 'untagged_vlan', 'tagged_vlans',
         ]
 
     def get_is_connected(self, obj):
@@ -685,8 +697,38 @@ class WritableInterfaceSerializer(ValidatedModelSerializer):
         model = Interface
         fields = [
             'id', 'device', 'name', 'form_factor', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', 'description',
+            'mode', 'untagged_vlan', 'tagged_vlans',
+        ]
+        ignore_validation_fields = [
+            'tagged_vlans'
         ]
 
+    def validate(self, data):
+
+        # Get the device for later use
+        if self.instance:
+            device = self.instance.device
+        else:
+            device = data.get('device')
+
+        # Validate VLANs belong to the device's site or global
+        # We have to do this here decause of the ManyToMany relationship
+        native_vlan = data.get('native_vlan')
+        if native_vlan:
+            if native_vlan.site != device.site and native_vlan.site is not None:
+                raise serializers.ValidationError("Native VLAN is invalid for the interface's device.")
+
+        tagged_vlan_members = data.get('tagged_vlan_members')
+        if tagged_vlan_members:
+            for vlan in tagged_vlan_members:
+                if vlan.site != device.site and vlan.site is not None:
+                    raise serializers.ValidationError("Tagged VLAN {} is invalid for the interface's device.".format(vlan))
+
+        # Enforce model validation
+        super(WritableInterfaceSerializer, self).validate(data)
+
+        return data
+
 
 #
 # Device bays

+ 9 - 0
netbox/dcim/constants.py

@@ -193,6 +193,15 @@ WIRELESS_IFACE_TYPES = [
 
 NONCONNECTABLE_IFACE_TYPES = VIRTUAL_IFACE_TYPES + WIRELESS_IFACE_TYPES
 
+IFACE_MODE_ACCESS = 100
+IFACE_MODE_TAGGED = 200
+IFACE_MODE_TAGGED_ALL = 300
+IFACE_MODE_CHOICES = [
+    [IFACE_MODE_ACCESS, 'Access'],
+    [IFACE_MODE_TAGGED, 'Tagged'],
+    [IFACE_MODE_TAGGED_ALL, 'Tagged All'],
+]
+
 # Device statuses
 STATUS_OFFLINE = 0
 STATUS_ACTIVE = 1

+ 251 - 10
netbox/dcim/forms.py

@@ -9,14 +9,15 @@ from django.db.models import Count, Q
 from mptt.forms import TreeNodeChoiceField
 
 from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
-from ipam.models import IPAddress
+from ipam.models import IPAddress, VLAN, VLANGroup
 from tenancy.forms import TenancyForm
 from tenancy.models import Tenant
 from utilities.forms import (
-    APISelect, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect,
-    ChainedFieldsMixin, ChainedModelChoiceField, CommentField, ComponentForm, ConfirmationForm, CSVChoiceField,
-    ExpandableNameField, FilterChoiceField, FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SmallTextarea,
-    SlugField, FilterTreeNodeMultipleChoiceField,
+    APISelect, APISelectMultiple, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm,
+    BulkEditNullBooleanSelect, ChainedFieldsMixin, ChainedModelChoiceField, ChainedModelMultipleChoiceField,
+    CommentField, ComponentForm, ConfirmationForm, CSVChoiceField, ExpandableNameField, FilterChoiceField,
+    FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SmallTextarea, SlugField,
+    FilterTreeNodeMultipleChoiceField,
 )
 from virtualization.models import Cluster
 from .constants import (
@@ -31,6 +32,7 @@ from .models import (
     Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation,
     RackRole, Region, Site,
 )
+from .constants import *
 
 DEVICE_BY_PK_RE = '{\d+\}'
 
@@ -1601,11 +1603,59 @@ class PowerOutletBulkDisconnectForm(ConfirmationForm):
 # Interfaces
 #
 
-class InterfaceForm(BootstrapMixin, forms.ModelForm):
+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}}',
+        )
+    )
+    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}}',
+        )
+    )
 
     class Meta:
         model = Interface
-        fields = ['device', 'name', 'form_factor', 'enabled', 'lag', 'mac_address', 'mtu', 'mgmt_only', 'description']
+        fields = [
+            'device', 'name', 'form_factor', 'enabled', 'lag', 'mac_address', 'mtu', 'mgmt_only',
+            'description', 'mode', 'site', 'vlan_group', 'untagged_vlan', 'tagged_vlans',
+        ]
         widgets = {
             'device': forms.HiddenInput(),
         }
@@ -1618,13 +1668,65 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm):
             self.fields['lag'].queryset = Interface.objects.order_naturally().filter(
                 device_id=self.data['device'], form_factor=IFACE_FF_LAG
             )
+            device = Device.objects.get(pk=self.data['device'])
         else:
             self.fields['lag'].queryset = Interface.objects.order_naturally().filter(
                 device=self.instance.device, form_factor=IFACE_FF_LAG
             )
+            device = self.instance.device
 
+        # 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
 
-class InterfaceCreateForm(ComponentForm):
+        # Limit the initial vlan choices
+        if self.is_bound:
+            filter_dict = {
+                'group_id': self.data.get('vlan_group') or None,
+                'site_id': self.data.get('site') or None,
+            }
+        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):
+        """
+        Becasue 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']
+
+
+class InterfaceCreateForm(ComponentForm, ChainedFieldsMixin):
     name_pattern = ExpandableNameField(label='Name')
     form_factor = forms.ChoiceField(choices=IFACE_FF_CHOICES)
     enabled = forms.BooleanField(required=False)
@@ -1633,6 +1735,51 @@ class InterfaceCreateForm(ComponentForm):
     mac_address = MACAddressFormField(required=False, label='MAC Address')
     mgmt_only = forms.BooleanField(required=False, label='OOB Management')
     description = forms.CharField(max_length=100, required=False)
+    mode = forms.ChoiceField(choices=IFACE_MODE_CHOICES)
+    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):
 
@@ -1650,8 +1797,41 @@ class InterfaceCreateForm(ComponentForm):
         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:
+            filter_dict = {
+                'group_id': self.data.get('vlan_group') or None,
+                'site_id': self.data.get('site') or None,
+            }
+        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):
+class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm, ChainedFieldsMixin):
     pk = forms.ModelMultipleChoiceField(queryset=Interface.objects.all(), widget=forms.MultipleHiddenInput)
     device = forms.ModelChoiceField(queryset=Device.objects.all(), widget=forms.HiddenInput)
     form_factor = forms.ChoiceField(choices=add_blank_choice(IFACE_FF_CHOICES), required=False)
@@ -1660,9 +1840,54 @@ class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm):
     mtu = forms.IntegerField(required=False, min_value=1, max_value=32767, label='MTU')
     mgmt_only = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect, label='Management only')
     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}}',
+        )
+    )
 
     class Meta:
-        nullable_fields = ['lag', 'mtu', 'description']
+        nullable_fields = ['lag', 'mtu', 'description', 'untagged_vlan', 'tagged_vlans']
 
     def __init__(self, *args, **kwargs):
         super(InterfaceBulkEditForm, self).__init__(*args, **kwargs)
@@ -1682,6 +1907,22 @@ class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm):
         else:
             self.fields['lag'].choices = []
 
+        # 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
+
+        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 InterfaceBulkDisconnectForm(ConfirmationForm):
     pk = forms.ModelMultipleChoiceField(queryset=Interface.objects.all(), widget=forms.MultipleHiddenInput)

+ 32 - 0
netbox/dcim/migrations/0050_interface_vlan_tagging.py

@@ -0,0 +1,32 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.6 on 2017-11-10 20:10
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('ipam', '0020_ipaddress_add_role_carp'),
+        ('dcim', '0049_rackreservation_change_user'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='interface',
+            name='mode',
+            field=models.PositiveSmallIntegerField(choices=[[100, 'Access'], [200, 'Tagged'], [300, 'Tagged All']], default=100),
+        ),
+        migrations.AddField(
+            model_name='interface',
+            name='tagged_vlans',
+            field=models.ManyToManyField(blank=True, related_name='interfaces_as_tagged', to='ipam.VLAN', verbose_name='Tagged VLANs'),
+        ),
+        migrations.AddField(
+            model_name='interface',
+            name='untagged_vlan',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='interfaces_as_untagged', to='ipam.VLAN', verbose_name='Untagged VLAN'),
+        ),
+    ]

+ 14 - 0
netbox/dcim/models.py

@@ -1244,6 +1244,20 @@ class Interface(models.Model):
         help_text="This interface is used only for out-of-band management"
     )
     description = models.CharField(max_length=100, blank=True)
+    mode = models.PositiveSmallIntegerField(choices=IFACE_MODE_CHOICES, default=IFACE_MODE_ACCESS)
+    untagged_vlan = models.ForeignKey(
+        to='ipam.VLAN',
+        null=True,
+        blank=True,
+        verbose_name='Untagged VLAN',
+        related_name='interfaces_as_untagged'
+    )
+    tagged_vlans = models.ManyToManyField(
+        to='ipam.VLAN',
+        blank=True,
+        verbose_name='Tagged VLANs',
+        related_name='interfaces_as_tagged'
+    )
 
     objects = InterfaceQuerySet.as_manager()
 

+ 1 - 0
netbox/dcim/views.py

@@ -1485,6 +1485,7 @@ class InterfaceEditView(PermissionRequiredMixin, ComponentEditView):
     model = Interface
     parent_field = 'device'
     model_form = forms.InterfaceForm
+    template_name = 'dcim/interface_edit.html'
 
 
 class InterfaceDeleteView(PermissionRequiredMixin, ComponentDeleteView):

+ 53 - 47
netbox/project-static/js/forms.js

@@ -71,59 +71,65 @@ $(document).ready(function() {
     $('select[filter-for]').change(function() {
 
         // Resolve child field by ID specified in parent
-        var child_name = $(this).attr('filter-for');
-        var child_field = $('#id_' + child_name);
-        var child_selected = child_field.val();
-
-        // Wipe out any existing options within the child field and create a default option
-        child_field.empty();
-        if (!child_field.attr('multiple')) {
-            child_field.append($("<option></option>").attr("value", "").text("---------"));
-        }
+        var child_names = $(this).attr('filter-for');
+        var parent = this;
 
-        if ($(this).val() || $(this).attr('nullable') == 'true') {
-            var api_url = child_field.attr('api-url') + '&limit=1000';
-            var disabled_indicator = child_field.attr('disabled-indicator');
-            var initial_value = child_field.attr('initial');
-            var display_field = child_field.attr('display-field') || 'name';
-
-            // Determine the filter fields needed to make an API call
-            var filter_regex = /\{\{([a-z_]+)\}\}/g;
-            var match;
-            var rendered_url = api_url;
-            while (match = filter_regex.exec(api_url)) {
-                var filter_field = $('#id_' + match[1]);
-                if (filter_field.val()) {
-                    rendered_url = rendered_url.replace(match[0], filter_field.val());
-                } else if (filter_field.attr('nullable') == 'true') {
-                    rendered_url = rendered_url.replace(match[0], '0');
-                }
+        // allow more than one child
+        $.each(child_names.split(" "), function(_, child_name){
+
+            var child_field = $('#id_' + child_name);
+            var child_selected = child_field.val();
+
+            // Wipe out any existing options within the child field and create a default option
+            child_field.empty();
+            if (!child_field.attr('multiple')) {
+                child_field.append($("<option></option>").attr("value", "").text("---------"));
             }
 
-            // If all URL variables have been replaced, make the API call
-            if (rendered_url.search('{{') < 0) {
-                console.log(child_name + ": Fetching " + rendered_url);
-                $.ajax({
-                    url: rendered_url,
-                    dataType: 'json',
-                    success: function(response, status) {
-                        $.each(response.results, function(index, choice) {
-                            var option = $("<option></option>").attr("value", choice.id).text(choice[display_field]);
-                            if (disabled_indicator && choice[disabled_indicator] && choice.id != initial_value) {
-                                option.attr("disabled", "disabled");
-                            } else if (choice.id == child_selected) {
-                                option.attr("selected", "selected");
-                            }
-                            child_field.append(option);
-                        });
+            if ($(parent).val() || $(parent).attr('nullable') == 'true') {
+                var api_url = child_field.attr('api-url') + '&limit=1000';
+                var disabled_indicator = child_field.attr('disabled-indicator');
+                var initial_value = child_field.attr('initial');
+                var display_field = child_field.attr('display-field') || 'name';
+
+                // Determine the filter fields needed to make an API call
+                var filter_regex = /\{\{([a-z_]+)\}\}/g;
+                var match;
+                var rendered_url = api_url;
+                while (match = filter_regex.exec(api_url)) {
+                    var filter_field = $('#id_' + match[1]);
+                    if (filter_field.val()) {
+                        rendered_url = rendered_url.replace(match[0], filter_field.val());
+                    } else if (filter_field.attr('nullable') == 'true') {
+                        rendered_url = rendered_url.replace(match[0], '0');
                     }
-                });
-            }
+                }
 
-        }
+                // If all URL variables have been replaced, make the API call
+                if (rendered_url.search('{{') < 0) {
+                    console.log(child_name + ": Fetching " + rendered_url);
+                    $.ajax({
+                        url: rendered_url,
+                        dataType: 'json',
+                        success: function(response, status) {
+                            $.each(response.results, function(index, choice) {
+                                var option = $("<option></option>").attr("value", choice.id).text(choice[display_field]);
+                                if (disabled_indicator && choice[disabled_indicator] && choice.id != initial_value) {
+                                    option.attr("disabled", "disabled");
+                                } else if (choice.id == child_selected) {
+                                    option.attr("selected", "selected");
+                                }
+                                child_field.append(option);
+                            });
+                        }
+                    });
+                }
+
+            }
 
-        // Trigger change event in case the child field is the parent of another field
-        child_field.change();
+            // Trigger change event in case the child field is the parent of another field
+            child_field.change();
+        });
 
     });
 });

+ 28 - 0
netbox/templates/dcim/interface_edit.html

@@ -0,0 +1,28 @@
+{% 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.form_factor %}
+            {% render_field form.enabled %}
+            {% render_field form.lag %}
+            {% render_field form.mac_address %}
+            {% 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>
+{% endblock %}

+ 5 - 0
netbox/utilities/api.py

@@ -48,6 +48,11 @@ class ValidatedModelSerializer(ModelSerializer):
         attrs = data.copy()
         attrs.pop('custom_fields', None)
 
+        # remove any fields marked for no validation
+        ignore_validation_fields = getattr(self.Meta, 'ignore_validation_fields', [])
+        for field in ignore_validation_fields:
+            attrs.pop(field)
+
         # Run clean() on an instance of the model
         if self.instance is None:
             instance = self.Meta.model(**attrs)

+ 7 - 0
netbox/utilities/constants.py

@@ -0,0 +1,7 @@
+from utilities.forms import ChainedModelMultipleChoiceField
+
+
+# Fields which are used on ManyToMany relationships
+M2M_FIELD_TYPES = [
+    ChainedModelMultipleChoiceField,
+]

+ 20 - 0
netbox/utilities/views.py

@@ -766,6 +766,26 @@ class ComponentCreateView(View):
 
             if not form.errors:
                 self.model.objects.bulk_create(new_components)
+
+                # ManyToMany relations are bulk created via the through model
+                m2m_fields = [field for field in component_form.fields if type(component_form.fields[field]) in M2M_FIELD_TYPES]
+                if m2m_fields:
+                    for field in m2m_fields:
+                        field_links = []
+                        for new_component in new_components:
+                            for related_obj in component_form.cleaned_data[field]:
+                                # The through model columns are the id's of our M2M relation objects
+                                through_kwargs = {}
+                                new_component_column = new_component.__class__.__name__ + '_id'
+                                related_obj_column = related_obj.__class__.__name__ + '_id'
+                                through_kwargs.update({
+                                    new_component_column.lower(): new_component.id,
+                                    related_obj_column.lower(): related_obj.id
+                                })
+                                field_link = getattr(self.model, field).through(**through_kwargs)
+                                field_links.append(field_link)
+                        getattr(self.model, field).through.objects.bulk_create(field_links)
+
                 messages.success(request, "Added {} {} to {}.".format(
                     len(new_components), self.model._meta.verbose_name_plural, parent
                 ))

+ 5 - 0
netbox/virtualization/models.py

@@ -267,3 +267,8 @@ class VirtualMachine(CreatedUpdatedModel, CustomFieldModel):
             return self.primary_ip4
         else:
             return None
+
+    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