Browse Source

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

Jeremy Stretch 7 years ago
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 circuits.models import Circuit, CircuitTermination
 from dcim.constants import (
 from dcim.constants import (
-    CONNECTION_STATUS_CHOICES, IFACE_FF_CHOICES, IFACE_ORDERING_CHOICES, RACK_FACE_CHOICES, RACK_TYPE_CHOICES,
+    CONNECTION_STATUS_CHOICES, IFACE_FF_CHOICES, IFACE_MODE_CHOICES, IFACE_ORDERING_CHOICES, RACK_FACE_CHOICES,
-    RACK_WIDTH_CHOICES, STATUS_CHOICES, SUBDEVICE_ROLE_CHOICES,
+    RACK_TYPE_CHOICES, RACK_WIDTH_CHOICES, STATUS_CHOICES, SUBDEVICE_ROLE_CHOICES,
 )
 )
 from dcim.models import (
 from dcim.models import (
     ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
     ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
@@ -17,7 +17,7 @@ from dcim.models import (
     RackReservation, RackRole, Region, Site,
     RackReservation, RackRole, Region, Site,
 )
 )
 from extras.api.customfields import CustomFieldModelSerializer
 from extras.api.customfields import CustomFieldModelSerializer
-from ipam.models import IPAddress
+from ipam.models import IPAddress, VLAN
 from tenancy.api.serializers import NestedTenantSerializer
 from tenancy.api.serializers import NestedTenantSerializer
 from utilities.api import ChoiceFieldSerializer, ValidatedModelSerializer
 from utilities.api import ChoiceFieldSerializer, ValidatedModelSerializer
 from virtualization.models import Cluster
 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):
 class InterfaceSerializer(serializers.ModelSerializer):
     device = NestedDeviceSerializer()
     device = NestedDeviceSerializer()
     form_factor = ChoiceFieldSerializer(choices=IFACE_FF_CHOICES)
     form_factor = ChoiceFieldSerializer(choices=IFACE_FF_CHOICES)
@@ -635,12 +644,15 @@ class InterfaceSerializer(serializers.ModelSerializer):
     is_connected = serializers.SerializerMethodField(read_only=True)
     is_connected = serializers.SerializerMethodField(read_only=True)
     interface_connection = serializers.SerializerMethodField(read_only=True)
     interface_connection = serializers.SerializerMethodField(read_only=True)
     circuit_termination = InterfaceCircuitTerminationSerializer()
     circuit_termination = InterfaceCircuitTerminationSerializer()
+    untagged_vlan = InterfaceVLANSerializer()
+    mode = ChoiceFieldSerializer(choices=IFACE_MODE_CHOICES)
+    tagged_vlans = InterfaceVLANSerializer(many=True)
 
 
     class Meta:
     class Meta:
         model = Interface
         model = Interface
         fields = [
         fields = [
             'id', 'device', 'name', 'form_factor', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', 'description',
             '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):
     def get_is_connected(self, obj):
@@ -685,8 +697,38 @@ class WritableInterfaceSerializer(ValidatedModelSerializer):
         model = Interface
         model = Interface
         fields = [
         fields = [
             'id', 'device', 'name', 'form_factor', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', 'description',
             '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
 # Device bays

+ 9 - 0
netbox/dcim/constants.py

@@ -193,6 +193,15 @@ WIRELESS_IFACE_TYPES = [
 
 
 NONCONNECTABLE_IFACE_TYPES = VIRTUAL_IFACE_TYPES + 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
 # Device statuses
 STATUS_OFFLINE = 0
 STATUS_OFFLINE = 0
 STATUS_ACTIVE = 1
 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 mptt.forms import TreeNodeChoiceField
 
 
 from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
 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.forms import TenancyForm
 from tenancy.models import Tenant
 from tenancy.models import Tenant
 from utilities.forms import (
 from utilities.forms import (
-    APISelect, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect,
+    APISelect, APISelectMultiple, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm,
-    ChainedFieldsMixin, ChainedModelChoiceField, CommentField, ComponentForm, ConfirmationForm, CSVChoiceField,
+    BulkEditNullBooleanSelect, ChainedFieldsMixin, ChainedModelChoiceField, ChainedModelMultipleChoiceField,
-    ExpandableNameField, FilterChoiceField, FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SmallTextarea,
+    CommentField, ComponentForm, ConfirmationForm, CSVChoiceField, ExpandableNameField, FilterChoiceField,
-    SlugField, FilterTreeNodeMultipleChoiceField,
+    FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SmallTextarea, SlugField,
+    FilterTreeNodeMultipleChoiceField,
 )
 )
 from virtualization.models import Cluster
 from virtualization.models import Cluster
 from .constants import (
 from .constants import (
@@ -31,6 +32,7 @@ from .models import (
     Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation,
     Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation,
     RackRole, Region, Site,
     RackRole, Region, Site,
 )
 )
+from .constants import *
 
 
 DEVICE_BY_PK_RE = '{\d+\}'
 DEVICE_BY_PK_RE = '{\d+\}'
 
 
@@ -1601,11 +1603,59 @@ class PowerOutletBulkDisconnectForm(ConfirmationForm):
 # Interfaces
 # 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:
     class Meta:
         model = Interface
         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 = {
         widgets = {
             'device': forms.HiddenInput(),
             'device': forms.HiddenInput(),
         }
         }
@@ -1618,13 +1668,65 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm):
             self.fields['lag'].queryset = Interface.objects.order_naturally().filter(
             self.fields['lag'].queryset = Interface.objects.order_naturally().filter(
                 device_id=self.data['device'], form_factor=IFACE_FF_LAG
                 device_id=self.data['device'], form_factor=IFACE_FF_LAG
             )
             )
+            device = Device.objects.get(pk=self.data['device'])
         else:
         else:
             self.fields['lag'].queryset = Interface.objects.order_naturally().filter(
             self.fields['lag'].queryset = Interface.objects.order_naturally().filter(
                 device=self.instance.device, form_factor=IFACE_FF_LAG
                 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')
     name_pattern = ExpandableNameField(label='Name')
     form_factor = forms.ChoiceField(choices=IFACE_FF_CHOICES)
     form_factor = forms.ChoiceField(choices=IFACE_FF_CHOICES)
     enabled = forms.BooleanField(required=False)
     enabled = forms.BooleanField(required=False)
@@ -1633,6 +1735,51 @@ class InterfaceCreateForm(ComponentForm):
     mac_address = MACAddressFormField(required=False, label='MAC Address')
     mac_address = MACAddressFormField(required=False, label='MAC Address')
     mgmt_only = forms.BooleanField(required=False, label='OOB Management')
     mgmt_only = forms.BooleanField(required=False, label='OOB Management')
     description = forms.CharField(max_length=100, required=False)
     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):
     def __init__(self, *args, **kwargs):
 
 
@@ -1650,8 +1797,41 @@ class InterfaceCreateForm(ComponentForm):
         else:
         else:
             self.fields['lag'].queryset = Interface.objects.none()
             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)
     pk = forms.ModelMultipleChoiceField(queryset=Interface.objects.all(), widget=forms.MultipleHiddenInput)
     device = forms.ModelChoiceField(queryset=Device.objects.all(), widget=forms.HiddenInput)
     device = forms.ModelChoiceField(queryset=Device.objects.all(), widget=forms.HiddenInput)
     form_factor = forms.ChoiceField(choices=add_blank_choice(IFACE_FF_CHOICES), required=False)
     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')
     mtu = forms.IntegerField(required=False, min_value=1, max_value=32767, label='MTU')
     mgmt_only = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect, label='Management only')
     mgmt_only = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect, label='Management only')
     description = forms.CharField(max_length=100, required=False)
     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:
     class Meta:
-        nullable_fields = ['lag', 'mtu', 'description']
+        nullable_fields = ['lag', 'mtu', 'description', 'untagged_vlan', 'tagged_vlans']
 
 
     def __init__(self, *args, **kwargs):
     def __init__(self, *args, **kwargs):
         super(InterfaceBulkEditForm, self).__init__(*args, **kwargs)
         super(InterfaceBulkEditForm, self).__init__(*args, **kwargs)
@@ -1682,6 +1907,22 @@ class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm):
         else:
         else:
             self.fields['lag'].choices = []
             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):
 class InterfaceBulkDisconnectForm(ConfirmationForm):
     pk = forms.ModelMultipleChoiceField(queryset=Interface.objects.all(), widget=forms.MultipleHiddenInput)
     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"
         help_text="This interface is used only for out-of-band management"
     )
     )
     description = models.CharField(max_length=100, blank=True)
     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()
     objects = InterfaceQuerySet.as_manager()
 
 

+ 1 - 0
netbox/dcim/views.py

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

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

@@ -71,59 +71,65 @@ $(document).ready(function() {
     $('select[filter-for]').change(function() {
     $('select[filter-for]').change(function() {
 
 
         // Resolve child field by ID specified in parent
         // Resolve child field by ID specified in parent
-        var child_name = $(this).attr('filter-for');
+        var child_names = $(this).attr('filter-for');
-        var child_field = $('#id_' + child_name);
+        var parent = this;
-        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 ($(this).val() || $(this).attr('nullable') == 'true') {
+        // allow more than one child
-            var api_url = child_field.attr('api-url') + '&limit=1000';
+        $.each(child_names.split(" "), function(_, child_name){
-            var disabled_indicator = child_field.attr('disabled-indicator');
+
-            var initial_value = child_field.attr('initial');
+            var child_field = $('#id_' + child_name);
-            var display_field = child_field.attr('display-field') || 'name';
+            var child_selected = child_field.val();
-
+
-            // Determine the filter fields needed to make an API call
+            // Wipe out any existing options within the child field and create a default option
-            var filter_regex = /\{\{([a-z_]+)\}\}/g;
+            child_field.empty();
-            var match;
+            if (!child_field.attr('multiple')) {
-            var rendered_url = api_url;
+                child_field.append($("<option></option>").attr("value", "").text("---------"));
-            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 ($(parent).val() || $(parent).attr('nullable') == 'true') {
-            if (rendered_url.search('{{') < 0) {
+                var api_url = child_field.attr('api-url') + '&limit=1000';
-                console.log(child_name + ": Fetching " + rendered_url);
+                var disabled_indicator = child_field.attr('disabled-indicator');
-                $.ajax({
+                var initial_value = child_field.attr('initial');
-                    url: rendered_url,
+                var display_field = child_field.attr('display-field') || 'name';
-                    dataType: 'json',
+
-                    success: function(response, status) {
+                // Determine the filter fields needed to make an API call
-                        $.each(response.results, function(index, choice) {
+                var filter_regex = /\{\{([a-z_]+)\}\}/g;
-                            var option = $("<option></option>").attr("value", choice.id).text(choice[display_field]);
+                var match;
-                            if (disabled_indicator && choice[disabled_indicator] && choice.id != initial_value) {
+                var rendered_url = api_url;
-                                option.attr("disabled", "disabled");
+                while (match = filter_regex.exec(api_url)) {
-                            } else if (choice.id == child_selected) {
+                    var filter_field = $('#id_' + match[1]);
-                                option.attr("selected", "selected");
+                    if (filter_field.val()) {
-                            }
+                        rendered_url = rendered_url.replace(match[0], filter_field.val());
-                            child_field.append(option);
+                    } 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
+            // Trigger change event in case the child field is the parent of another field
-        child_field.change();
+            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 = data.copy()
         attrs.pop('custom_fields', None)
         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
         # Run clean() on an instance of the model
         if self.instance is None:
         if self.instance is None:
             instance = self.Meta.model(**attrs)
             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:
             if not form.errors:
                 self.model.objects.bulk_create(new_components)
                 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(
                 messages.success(request, "Added {} {} to {}.".format(
                     len(new_components), self.model._meta.verbose_name_plural, parent
                     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
             return self.primary_ip4
         else:
         else:
             return None
             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