Parcourir la source

Merge pull request #1987 from digitalocean/develop

Release v2.3.2
Jeremy Stretch il y a 7 ans
Parent
commit
68f73c7f94

+ 2 - 1
README.md

@@ -12,7 +12,7 @@ complete list of requirements, see `requirements.txt`. The code is available [on
 The complete documentation for NetBox can be found at [Read the Docs](http://netbox.readthedocs.io/en/stable/).
 
 Questions? Comments? Please subscribe to [the netbox-discuss mailing list](https://groups.google.com/forum/#!forum/netbox-discuss),
-or join us in the #netbox Slack channel on [NetworkToCode](https://slack.networktocode.com/)!
+or join us in the #netbox Slack channel on [NetworkToCode](https://networktocode.slack.com/)!
 
 ### Build Status
 
@@ -41,3 +41,4 @@ and run `upgrade.sh`.
 
 * [Docker container](https://github.com/ninech/netbox-docker) (via [@cimnine](https://github.com/cimnine))
 * [Vagrant deployment](https://github.com/ryanmerolle/netbox-vagrant) (via [@ryanmerolle](https://github.com/ryanmerolle))
+* [Ansible deployment](https://github.com/lae/ansible-role-netbox) (via [@lae](https://github.com/lae))

+ 2 - 0
docs/installation/web-server.md

@@ -82,6 +82,7 @@ Once Apache is installed, proceed with the following configuration (Be sure to m
         ProxyPass !
     </Location>
 
+    RequestHeader set "X-Forwarded-Proto" expr=%{REQUEST_SCHEME}
     ProxyPass / http://127.0.0.1:8001/
     ProxyPassReverse / http://127.0.0.1:8001/
 </VirtualHost>
@@ -92,6 +93,7 @@ Save the contents of the above example in `/etc/apache2/sites-available/netbox.c
 ```no-highlight
 # a2enmod proxy
 # a2enmod proxy_http
+# a2enmod headers
 # a2ensite netbox
 # service apache2 restart
 ```

+ 8 - 10
netbox/circuits/forms.py

@@ -8,8 +8,8 @@ from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFi
 from tenancy.forms import TenancyForm
 from tenancy.models import Tenant
 from utilities.forms import (
-    APISelect, add_blank_choice, BootstrapMixin, ChainedFieldsMixin, ChainedModelChoiceField, CommentField,
-    CSVChoiceField, FilterChoiceField, SmallTextarea, SlugField,
+    AnnotatedMultipleChoiceField, APISelect, add_blank_choice, BootstrapMixin, ChainedFieldsMixin,
+    ChainedModelChoiceField, CommentField, CSVChoiceField, FilterChoiceField, SmallTextarea, SlugField,
 )
 from .constants import CIRCUIT_STATUS_CHOICES
 from .models import Circuit, CircuitTermination, CircuitType, Provider
@@ -169,13 +169,6 @@ class CircuitBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
         nullable_fields = ['tenant', 'commit_rate', 'description', 'comments']
 
 
-def circuit_status_choices():
-    status_counts = {}
-    for status in Circuit.objects.values('status').annotate(count=Count('status')).order_by('status'):
-        status_counts[status['status']] = status['count']
-    return [(s[0], '{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in CIRCUIT_STATUS_CHOICES]
-
-
 class CircuitFilterForm(BootstrapMixin, CustomFieldFilterForm):
     model = Circuit
     q = forms.CharField(required=False, label='Search')
@@ -187,7 +180,12 @@ class CircuitFilterForm(BootstrapMixin, CustomFieldFilterForm):
         queryset=Provider.objects.annotate(filter_count=Count('circuits')),
         to_field_name='slug'
     )
-    status = forms.MultipleChoiceField(choices=circuit_status_choices, required=False)
+    status = AnnotatedMultipleChoiceField(
+        choices=CIRCUIT_STATUS_CHOICES,
+        annotate=Circuit.objects.all(),
+        annotate_field='status',
+        required=False
+    )
     tenant = FilterChoiceField(
         queryset=Tenant.objects.annotate(filter_count=Count('circuits')),
         to_field_name='slug',

+ 11 - 2
netbox/dcim/api/views.py

@@ -6,6 +6,9 @@ from django.conf import settings
 from django.db import transaction
 from django.http import HttpResponseBadRequest, HttpResponseForbidden
 from django.shortcuts import get_object_or_404
+from drf_yasg import openapi
+from drf_yasg.openapi import Parameter
+from drf_yasg.utils import swagger_auto_schema
 from rest_framework.decorators import detail_route
 from rest_framework.mixins import ListModelMixin
 from rest_framework.response import Response
@@ -418,14 +421,20 @@ class ConnectedDeviceViewSet(ViewSet):
     * `peer-interface`: The name of the peer interface
     """
     permission_classes = [IsAuthenticatedOrLoginNotRequired]
+    _device_param = Parameter('peer-device', 'query',
+                              description='The name of the peer device', required=True, type=openapi.TYPE_STRING)
+    _interface_param = Parameter('peer-interface', 'query',
+                                 description='The name of the peer interface', required=True, type=openapi.TYPE_STRING)
 
     def get_view_name(self):
         return "Connected Device Locator"
 
+    @swagger_auto_schema(
+        manual_parameters=[_device_param, _interface_param], responses={'200': serializers.DeviceSerializer})
     def list(self, request):
 
-        peer_device_name = request.query_params.get('peer-device')
-        peer_interface_name = request.query_params.get('peer-interface')
+        peer_device_name = request.query_params.get(self._device_param.name)
+        peer_interface_name = request.query_params.get(self._interface_param.name)
         if not peer_device_name or not peer_interface_name:
             raise MissingFilterException(detail='Request must include "peer-device" and "peer-interface" filters.')
 

+ 139 - 263
netbox/dcim/forms.py

@@ -14,11 +14,10 @@ from ipam.models import IPAddress, VLAN, VLANGroup
 from tenancy.forms import TenancyForm
 from tenancy.models import Tenant
 from utilities.forms import (
-    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 .constants import (
@@ -37,6 +36,12 @@ from .models import (
 
 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):
     """
@@ -172,17 +177,15 @@ class SiteBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
         nullable_fields = ['region', 'tenant', 'asn', 'description', 'time_zone']
 
 
-def site_status_choices():
-    status_counts = {}
-    for status in Site.objects.values('status').annotate(count=Count('status')).order_by('status'):
-        status_counts[status['status']] = status['count']
-    return [(s[0], '{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in SITE_STATUS_CHOICES]
-
-
 class SiteFilterForm(BootstrapMixin, CustomFieldFilterForm):
     model = Site
     q = forms.CharField(required=False, label='Search')
-    status = forms.MultipleChoiceField(choices=site_status_choices, required=False)
+    status = AnnotatedMultipleChoiceField(
+        choices=SITE_STATUS_CHOICES,
+        annotate=Site.objects.all(),
+        annotate_field='status',
+        required=False
+    )
     region = FilterTreeNodeMultipleChoiceField(
         queryset=Region.objects.annotate(filter_count=Count('sites')),
         to_field_name='slug',
@@ -700,13 +703,21 @@ class PlatformForm(BootstrapMixin, forms.ModelForm):
 
 class PlatformCSVForm(forms.ModelForm):
     slug = SlugField()
+    manufacturer = forms.ModelChoiceField(
+        queryset=Manufacturer.objects.all(),
+        required=True,
+        to_field_name='name',
+        help_text='Manufacturer name',
+        error_messages={
+            'invalid_choice': 'Manufacturer not found.',
+        }
+    )
 
     class Meta:
         model = Platform
         fields = Platform.csv_headers
         help_texts = {
             'name': 'Platform name',
-            'manufacturer': 'Manufacturer name',
         }
 
 
@@ -1040,13 +1051,6 @@ class DeviceBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
         nullable_fields = ['tenant', 'platform', 'serial']
 
 
-def device_status_choices():
-    status_counts = {}
-    for status in Device.objects.values('status').annotate(count=Count('status')).order_by('status'):
-        status_counts[status['status']] = status['count']
-    return [(s[0], '{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in DEVICE_STATUS_CHOICES]
-
-
 class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm):
     model = Device
     q = forms.CharField(required=False, label='Search')
@@ -1084,7 +1088,12 @@ class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm):
         to_field_name='slug',
         null_label='-- None --',
     )
-    status = forms.MultipleChoiceField(choices=device_status_choices, required=False)
+    status = AnnotatedMultipleChoiceField(
+        choices=DEVICE_STATUS_CHOICES,
+        annotate=Device.objects.all(),
+        annotate_field='status',
+        required=False
+    )
     mac_address = forms.CharField(required=False, label='MAC address')
     has_primary_ip = forms.NullBooleanField(
         required=False,
@@ -1648,63 +1657,23 @@ class PowerOutletBulkDisconnectForm(ConfirmationForm):
 # Interfaces
 #
 
-class InterfaceForm(BootstrapMixin, forms.ModelForm, ChainedFieldsMixin):
-    site = forms.ModelChoiceField(
-        queryset=Site.objects.all(),
-        required=False,
-        label='VLAN site',
-        widget=forms.Select(
-            attrs={'filter-for': 'vlan_group', 'nullable': 'true'},
-        )
-    )
-    vlan_group = ChainedModelChoiceField(
-        queryset=VLANGroup.objects.all(),
-        chains=(
-            ('site', 'site'),
-        ),
-        required=False,
-        label='VLAN group',
-        widget=APISelect(
-            attrs={'filter-for': 'untagged_vlan tagged_vlans', 'nullable': 'true'},
-            api_url='/api/ipam/vlan-groups/?site_id={{site}}',
-        )
-    )
-    untagged_vlan = ChainedModelChoiceField(
-        queryset=VLAN.objects.all(),
-        chains=(
-            ('site', 'site'),
-            ('group', 'vlan_group'),
-        ),
-        required=False,
-        label='Untagged VLAN',
-        widget=APISelect(
-            api_url='/api/ipam/vlans/?site_id={{site}}&group_id={{vlan_group}}',
-            display_field='display_name'
-        )
-    )
-    tagged_vlans = ChainedModelMultipleChoiceField(
-        queryset=VLAN.objects.all(),
-        chains=(
-            ('site', 'site'),
-            ('group', 'vlan_group'),
-        ),
-        required=False,
-        label='Tagged VLANs',
-        widget=APISelectMultiple(
-            api_url='/api/ipam/vlans/?site_id={{site}}&group_id={{vlan_group}}',
-            display_field='display_name'
-        )
-    )
+class InterfaceForm(BootstrapMixin, forms.ModelForm):
 
     class Meta:
         model = Interface
         fields = [
-            'device', 'name', 'form_factor', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', 'description',
-            'mode', 'site', 'vlan_group', 'untagged_vlan', 'tagged_vlans',
+            'device', 'name', 'form_factor', 'enabled', 'lag', 'mac_address', 'mtu', 'mgmt_only', 'description',
+            'mode', 'untagged_vlan', 'tagged_vlans',
         ]
         widgets = {
             'device': forms.HiddenInput(),
         }
+        labels = {
+            'mode': '802.1Q Mode',
+        }
+        help_texts = {
+            'mode': INTERFACE_MODE_HELP_TEXT,
+        }
 
     def __init__(self, *args, **kwargs):
         super(InterfaceForm, self).__init__(*args, **kwargs)
@@ -1721,58 +1690,108 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm, ChainedFieldsMixin):
                 device__in=[self.instance.device, self.instance.device.get_vc_master()], form_factor=IFACE_FF_LAG
             )
 
-        # Limit the queryset for the site to only include the interface's device's site
-        if device and device.site:
-            self.fields['site'].queryset = Site.objects.filter(pk=device.site.id)
-            self.fields['site'].initial = None
-        else:
-            self.fields['site'].queryset = Site.objects.none()
-            self.fields['site'].initial = None
-
-        # Limit the initial vlan choices
-        if self.is_bound and self.data.get('vlan_group') and self.data.get('site'):
-            filter_dict = {
-                'group_id': self.data.get('vlan_group'),
-                'site_id': self.data.get('site'),
-            }
-        elif self.initial.get('untagged_vlan'):
-            filter_dict = {
-                'group_id': self.instance.untagged_vlan.group,
-                'site_id': self.instance.untagged_vlan.site,
-            }
-        elif self.initial.get('tagged_vlans'):
-            filter_dict = {
-                'group_id': self.instance.tagged_vlans.first().group,
-                'site_id': self.instance.tagged_vlans.first().site,
-            }
-        else:
-            filter_dict = {
-                'group_id': None,
-                'site_id': None,
-            }
-
-        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):
-        """
-        Because tagged_vlans is a many-to-many relationship, validation must be done in the form
-        """
-        if self.cleaned_data['mode'] == IFACE_MODE_ACCESS and self.cleaned_data['tagged_vlans']:
-            raise forms.ValidationError(
-                "An Access interface cannot have tagged VLANs."
-            )
+    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 InterfaceAssignVLANsForm(BootstrapMixin, forms.ModelForm):
+    vlans = forms.MultipleChoiceField(
+        choices=[],
+        label='VLANs',
+        widget=forms.SelectMultiple(attrs={'size': 20})
+    )
+    tagged = forms.BooleanField(
+        required=False,
+        initial=True
+    )
+
+    class Meta:
+        model = Interface
+        fields = []
+
+    def __init__(self, *args, **kwargs):
+
+        super(InterfaceAssignVLANsForm, self).__init__(*args, **kwargs)
+
+        if self.instance.mode == IFACE_MODE_ACCESS:
+            self.initial['tagged'] = False
+
+        # Find all VLANs already assigned to the interface for exclusion from the list
+        assigned_vlans = [v.pk for v in self.instance.tagged_vlans.all()]
+        if self.instance.untagged_vlan is not None:
+            assigned_vlans.append(self.instance.untagged_vlan.pk)
+
+        # 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])
+        )
 
-        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."
+        # 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])
             )
 
-        return self.cleaned_data['tagged_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
+
+    def clean(self):
+
+        super(InterfaceAssignVLANsForm, self).clean()
+
+        # Only untagged VLANs permitted on an access interface
+        if self.instance.mode == IFACE_MODE_ACCESS and len(self.cleaned_data['vlans']) > 1:
+            raise forms.ValidationError("Only one VLAN may be assigned to an access interface.")
+
+        # 'tagged' is required if more than one VLAN is selected
+        if not self.cleaned_data['tagged'] and len(self.cleaned_data['vlans']) > 1:
+            raise forms.ValidationError("Only one untagged VLAN may be selected.")
 
+    def save(self, *args, **kwargs):
 
-class InterfaceCreateForm(ComponentForm, ChainedFieldsMixin):
+        if self.cleaned_data['tagged']:
+            for vlan in self.cleaned_data['vlans']:
+                self.instance.tagged_vlans.add(vlan)
+        else:
+            self.instance.untagged_vlan_id = self.cleaned_data['vlans'][0]
+
+        return super(InterfaceAssignVLANsForm, self).save(*args, **kwargs)
+
+
+class InterfaceCreateForm(ComponentForm, forms.Form):
     name_pattern = ExpandableNameField(label='Name')
     form_factor = forms.ChoiceField(choices=IFACE_FF_CHOICES)
     enabled = forms.BooleanField(required=False)
@@ -1786,50 +1805,6 @@ class InterfaceCreateForm(ComponentForm, ChainedFieldsMixin):
     )
     description = forms.CharField(max_length=100, required=False)
     mode = forms.ChoiceField(choices=add_blank_choice(IFACE_MODE_CHOICES), required=False)
-    site = forms.ModelChoiceField(
-        queryset=Site.objects.all(),
-        required=False,
-        label='VLAN Site',
-        widget=forms.Select(
-            attrs={'filter-for': 'vlan_group', 'nullable': 'true'},
-        )
-    )
-    vlan_group = ChainedModelChoiceField(
-        queryset=VLANGroup.objects.all(),
-        chains=(
-            ('site', 'site'),
-        ),
-        required=False,
-        label='VLAN group',
-        widget=APISelect(
-            attrs={'filter-for': 'untagged_vlan tagged_vlans', 'nullable': 'true'},
-            api_url='/api/ipam/vlan-groups/?site_id={{site}}',
-        )
-    )
-    untagged_vlan = ChainedModelChoiceField(
-        queryset=VLAN.objects.all(),
-        chains=(
-            ('site', 'site'),
-            ('group', 'vlan_group'),
-        ),
-        required=False,
-        label='Untagged VLAN',
-        widget=APISelect(
-            api_url='/api/ipam/vlans/?site_id={{site}}&group_id={{vlan_group}}',
-        )
-    )
-    tagged_vlans = ChainedModelMultipleChoiceField(
-        queryset=VLAN.objects.all(),
-        chains=(
-            ('site', 'site'),
-            ('group', 'vlan_group'),
-        ),
-        required=False,
-        label='Tagged VLANs',
-        widget=APISelectMultiple(
-            api_url='/api/ipam/vlans/?site_id={{site}}&group_id={{vlan_group}}',
-        )
-    )
 
     def __init__(self, *args, **kwargs):
 
@@ -1847,41 +1822,8 @@ class InterfaceCreateForm(ComponentForm, ChainedFieldsMixin):
         else:
             self.fields['lag'].queryset = Interface.objects.none()
 
-        # Limit the queryset for the site to only include the interface's device's site
-        if self.parent is not None and self.parent.site:
-            self.fields['site'].queryset = Site.objects.filter(pk=self.parent.site.id)
-            self.fields['site'].initial = None
-        else:
-            self.fields['site'].queryset = Site.objects.none()
-            self.fields['site'].initial = None
-
-        # Limit the initial vlan choices
-        if self.is_bound and self.data.get('vlan_group') and self.data.get('site'):
-            filter_dict = {
-                'group_id': self.data.get('vlan_group'),
-                'site_id': self.data.get('site'),
-            }
-        elif self.initial.get('untagged_vlan'):
-            filter_dict = {
-                'group_id': self.untagged_vlan.group,
-                'site_id': self.untagged_vlan.site,
-            }
-        elif self.initial.get('tagged_vlans'):
-            filter_dict = {
-                'group_id': self.tagged_vlans.first().group,
-                'site_id': self.tagged_vlans.first().site,
-            }
-        else:
-            filter_dict = {
-                'group_id': None,
-                'site_id': None,
-            }
-
-        self.fields['untagged_vlan'].queryset = VLAN.objects.filter(**filter_dict)
-        self.fields['tagged_vlans'].queryset = VLAN.objects.filter(**filter_dict)
-
 
-class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm, ChainedFieldsMixin):
+class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm):
     pk = forms.ModelMultipleChoiceField(queryset=Interface.objects.all(), widget=forms.MultipleHiddenInput)
     form_factor = forms.ChoiceField(choices=add_blank_choice(IFACE_FF_CHOICES), required=False)
     enabled = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect)
@@ -1890,53 +1832,9 @@ class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm, ChainedFieldsMixin):
     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', 'untagged_vlan', 'tagged_vlans']
+        nullable_fields = ['lag', 'mtu', 'description', 'mode']
 
     def __init__(self, *args, **kwargs):
         super(InterfaceBulkEditForm, self).__init__(*args, **kwargs)
@@ -1951,28 +1849,6 @@ class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm, ChainedFieldsMixin):
         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
-
-        if self.is_bound and self.data.get('vlan_group') and self.data.get('site'):
-            filter_dict = {
-                'group_id': self.data.get('vlan_group'),
-                'site_id': self.data.get('site'),
-            }
-        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 InterfaceBulkRenameForm(BulkRenameForm):
     pk = forms.ModelMultipleChoiceField(queryset=Interface.objects.all(), widget=forms.MultipleHiddenInput)

+ 12 - 0
netbox/dcim/models.py

@@ -1455,6 +1455,18 @@ class Interface(models.Model):
                                  "device/VM, or it must be global".format(self.untagged_vlan)
             })
 
+    def save(self, *args, **kwargs):
+
+        # Remove untagged VLAN assignment for non-802.1Q interfaces
+        if self.mode is None:
+            self.untagged_vlan = None
+
+        # Only "tagged" interfaces may have tagged VLANs assigned. ("tagged all" implies all VLANs are assigned.)
+        if self.pk and self.mode is not IFACE_MODE_TAGGED:
+            self.tagged_vlans.clear()
+
+        return super(Interface, self).save(*args, **kwargs)
+
     @property
     def parent(self):
         return self.device or self.virtual_machine

+ 38 - 10
netbox/dcim/tables.py

@@ -47,8 +47,13 @@ REGION_ACTIONS = """
 """
 
 RACKGROUP_ACTIONS = """
+<a href="{% url 'dcim:rack_elevation_list' %}?site={{ record.site.slug }}&group_id={{ record.pk }}" class="btn btn-xs btn-primary" title="View elevations">
+    <i class="fa fa-eye"></i>
+</a>
 {% if perms.dcim.change_rackgroup %}
-    <a href="{% url 'dcim:rackgroup_edit' pk=record.pk %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
+    <a href="{% url 'dcim:rackgroup_edit' pk=record.pk %}" class="btn btn-xs btn-warning" title="Edit">
+        <i class="glyphicon glyphicon-pencil"></i>
+    </a>
 {% endif %}
 """
 
@@ -128,6 +133,10 @@ SUBDEVICE_ROLE_TEMPLATE = """
 {% if record.subdevice_role == True %}Parent{% elif record.subdevice_role == False %}Child{% else %}&mdash;{% endif %}
 """
 
+DEVICETYPE_INSTANCES_TEMPLATE = """
+<a href="{% url 'dcim:device_list' %}?manufacturer_id={{ record.manufacturer_id }}&device_type_id={{ record.pk }}">{{ record.instance_count }}</a>
+"""
+
 UTILIZATION_GRAPH = """
 {% load helpers %}
 {% utilization_graph value %}
@@ -182,12 +191,21 @@ class SiteTable(BaseTable):
 
 class RackGroupTable(BaseTable):
     pk = ToggleColumn()
-    name = tables.LinkColumn(verbose_name='Name')
-    site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
-    rack_count = tables.Column(verbose_name='Racks')
-    slug = tables.Column(verbose_name='Slug')
-    actions = tables.TemplateColumn(template_code=RACKGROUP_ACTIONS, attrs={'td': {'class': 'text-right'}},
-                                    verbose_name='')
+    name = tables.LinkColumn()
+    site = tables.LinkColumn(
+        viewname='dcim:site',
+        args=[Accessor('site.slug')],
+        verbose_name='Site'
+    )
+    rack_count = tables.Column(
+        verbose_name='Racks'
+    )
+    slug = tables.Column()
+    actions = tables.TemplateColumn(
+        template_code=RACKGROUP_ACTIONS,
+        attrs={'td': {'class': 'text-right'}},
+        verbose_name=''
+    )
 
     class Meta(BaseTable.Meta):
         model = RackGroup
@@ -299,13 +317,23 @@ class ManufacturerTable(BaseTable):
 
 class DeviceTypeTable(BaseTable):
     pk = ToggleColumn()
-    model = tables.LinkColumn('dcim:devicetype', args=[Accessor('pk')], verbose_name='Device Type')
+    model = tables.LinkColumn(
+        viewname='dcim:devicetype',
+        args=[Accessor('pk')],
+        verbose_name='Device Type'
+    )
     is_full_depth = tables.BooleanColumn(verbose_name='Full Depth')
     is_console_server = tables.BooleanColumn(verbose_name='CS')
     is_pdu = tables.BooleanColumn(verbose_name='PDU')
     is_network_device = tables.BooleanColumn(verbose_name='Net')
-    subdevice_role = tables.TemplateColumn(SUBDEVICE_ROLE_TEMPLATE, verbose_name='Subdevice Role')
-    instance_count = tables.Column(verbose_name='Instances')
+    subdevice_role = tables.TemplateColumn(
+        template_code=SUBDEVICE_ROLE_TEMPLATE,
+        verbose_name='Subdevice Role'
+    )
+    instance_count = tables.TemplateColumn(
+        template_code=DEVICETYPE_INSTANCES_TEMPLATE,
+        verbose_name='Instances'
+    )
 
     class Meta(BaseTable.Meta):
         model = DeviceType

+ 7 - 1
netbox/dcim/tests/test_api.py

@@ -5,7 +5,9 @@ from django.urls import reverse
 from rest_framework import status
 from rest_framework.test import APITestCase
 
-from dcim.constants import IFACE_FF_1GE_FIXED, IFACE_FF_LAG, SUBDEVICE_ROLE_CHILD, SUBDEVICE_ROLE_PARENT
+from dcim.constants import (
+    IFACE_FF_1GE_FIXED, IFACE_FF_LAG, IFACE_MODE_TAGGED, SUBDEVICE_ROLE_CHILD, SUBDEVICE_ROLE_PARENT,
+)
 from dcim.models import (
     ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
     DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer,
@@ -2319,6 +2321,7 @@ class InterfaceTest(HttpStatusMixin, APITestCase):
         data = {
             'device': self.device.pk,
             'name': 'Test Interface 4',
+            'mode': IFACE_MODE_TAGGED,
             'tagged_vlans': [self.vlan1.id, self.vlan2.id],
             'untagged_vlan': self.vlan3.id
         }
@@ -2366,18 +2369,21 @@ class InterfaceTest(HttpStatusMixin, APITestCase):
             {
                 'device': self.device.pk,
                 'name': 'Test Interface 4',
+                'mode': IFACE_MODE_TAGGED,
                 'tagged_vlans': [self.vlan1.id],
                 'untagged_vlan': self.vlan2.id,
             },
             {
                 'device': self.device.pk,
                 'name': 'Test Interface 5',
+                'mode': IFACE_MODE_TAGGED,
                 'tagged_vlans': [self.vlan1.id],
                 'untagged_vlan': self.vlan2.id,
             },
             {
                 'device': self.device.pk,
                 'name': 'Test Interface 6',
+                'mode': IFACE_MODE_TAGGED,
                 'tagged_vlans': [self.vlan1.id],
                 'untagged_vlan': self.vlan2.id,
             },

+ 1 - 0
netbox/dcim/urls.py

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

+ 10 - 6
netbox/dcim/views.py

@@ -962,11 +962,9 @@ class DeviceLLDPNeighborsView(PermissionRequiredMixin, View):
     def get(self, request, pk):
 
         device = get_object_or_404(Device, pk=pk)
-        interfaces = Interface.objects.order_naturally(
+        interfaces = device.vc_interfaces.order_naturally(
             device.device_type.interface_ordering
-        ).connectable().filter(
-            device=device
-        ).select_related(
+        ).connectable().select_related(
             'connected_as_a', 'connected_as_b'
         )
 
@@ -1645,6 +1643,12 @@ class InterfaceEditView(PermissionRequiredMixin, ObjectEditView):
     template_name = 'dcim/interface_edit.html'
 
 
+class InterfaceAssignVLANsView(PermissionRequiredMixin, ObjectEditView):
+    permission_required = 'dcim.change_interface'
+    model = Interface
+    model_form = forms.InterfaceAssignVLANsForm
+
+
 class InterfaceDeleteView(PermissionRequiredMixin, ObjectDeleteView):
     permission_required = 'dcim.delete_interface'
     model = Interface
@@ -2226,7 +2230,7 @@ class VirtualChassisAddMemberView(PermissionRequiredMixin, GetReturnURLMixin, Vi
             device = member_select_form.cleaned_data['device']
             device.virtual_chassis = virtual_chassis
             data = {k: request.POST[k] for k in ['vc_position', 'vc_priority']}
-            membership_form = forms.DeviceVCMembershipForm(data, validate_vc_position=True, instance=device)
+            membership_form = forms.DeviceVCMembershipForm(data=data, validate_vc_position=True, instance=device)
 
             if membership_form.is_valid():
 
@@ -2242,7 +2246,7 @@ class VirtualChassisAddMemberView(PermissionRequiredMixin, GetReturnURLMixin, Vi
 
         else:
 
-            membership_form = forms.DeviceVCMembershipForm(request.POST)
+            membership_form = forms.DeviceVCMembershipForm(data=request.POST)
 
         return render(request, 'dcim/virtualchassis_add_member.html', {
             'virtual_chassis': virtual_chassis,

+ 1 - 1
netbox/extras/models.py

@@ -127,7 +127,7 @@ class CustomField(models.Model):
         """
         Convert a string into the object it represents depending on the type of field
         """
-        if serialized_value is '':
+        if serialized_value == '':
             return None
         if self.type == CF_TYPE_INTEGER:
             return int(serialized_value)

+ 27 - 35
netbox/ipam/forms.py

@@ -9,9 +9,9 @@ from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFi
 from tenancy.forms import TenancyForm
 from tenancy.models import Tenant
 from utilities.forms import (
-    APISelect, BootstrapMixin, BulkEditNullBooleanSelect, ChainedModelChoiceField, CSVChoiceField,
-    ExpandableIPAddressField, FilterChoiceField, FlexibleModelChoiceField, Livesearch, ReturnURLForm, SlugField,
-    add_blank_choice,
+    AnnotatedMultipleChoiceField, APISelect, BootstrapMixin, BulkEditNullBooleanSelect, ChainedModelChoiceField,
+    CSVChoiceField, ExpandableIPAddressField, FilterChoiceField, FlexibleModelChoiceField, Livesearch, ReturnURLForm,
+    SlugField, add_blank_choice,
 )
 from virtualization.models import VirtualMachine
 from .constants import IPADDRESS_ROLE_CHOICES, IPADDRESS_STATUS_CHOICES, PREFIX_STATUS_CHOICES, VLAN_STATUS_CHOICES
@@ -350,13 +350,6 @@ class PrefixBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
         nullable_fields = ['site', 'vrf', 'tenant', 'role', 'description']
 
 
-def prefix_status_choices():
-    status_counts = {}
-    for status in Prefix.objects.values('status').annotate(count=Count('status')).order_by('status'):
-        status_counts[status['status']] = status['count']
-    return [(s[0], '{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in PREFIX_STATUS_CHOICES]
-
-
 class PrefixFilterForm(BootstrapMixin, CustomFieldFilterForm):
     model = Prefix
     q = forms.CharField(required=False, label='Search')
@@ -376,7 +369,12 @@ class PrefixFilterForm(BootstrapMixin, CustomFieldFilterForm):
         to_field_name='slug',
         null_label='-- None --'
     )
-    status = forms.MultipleChoiceField(choices=prefix_status_choices, required=False)
+    status = AnnotatedMultipleChoiceField(
+        choices=PREFIX_STATUS_CHOICES,
+        annotate=Prefix.objects.all(),
+        annotate_field='status',
+        required=False
+    )
     site = FilterChoiceField(
         queryset=Site.objects.annotate(filter_count=Count('prefixes')),
         to_field_name='slug',
@@ -688,20 +686,6 @@ class IPAddressAssignForm(BootstrapMixin, forms.Form):
     address = forms.CharField(label='IP Address')
 
 
-def ipaddress_status_choices():
-    status_counts = {}
-    for status in IPAddress.objects.values('status').annotate(count=Count('status')).order_by('status'):
-        status_counts[status['status']] = status['count']
-    return [(s[0], '{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in IPADDRESS_STATUS_CHOICES]
-
-
-def ipaddress_role_choices():
-    role_counts = {}
-    for role in IPAddress.objects.values('role').annotate(count=Count('role')).order_by('role'):
-        role_counts[role['role']] = role['count']
-    return [(r[0], '{} ({})'.format(r[1], role_counts.get(r[0], 0))) for r in IPADDRESS_ROLE_CHOICES]
-
-
 class IPAddressFilterForm(BootstrapMixin, CustomFieldFilterForm):
     model = IPAddress
     q = forms.CharField(required=False, label='Search')
@@ -721,8 +705,18 @@ class IPAddressFilterForm(BootstrapMixin, CustomFieldFilterForm):
         to_field_name='slug',
         null_label='-- None --'
     )
-    status = forms.MultipleChoiceField(choices=ipaddress_status_choices, required=False)
-    role = forms.MultipleChoiceField(choices=ipaddress_role_choices, required=False)
+    status = AnnotatedMultipleChoiceField(
+        choices=IPADDRESS_STATUS_CHOICES,
+        annotate=IPAddress.objects.all(),
+        annotate_field='status',
+        required=False
+    )
+    role = AnnotatedMultipleChoiceField(
+        choices=IPADDRESS_ROLE_CHOICES,
+        annotate=IPAddress.objects.all(),
+        annotate_field='role',
+        required=False
+    )
 
 
 #
@@ -878,13 +872,6 @@ class VLANBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
         nullable_fields = ['site', 'group', 'tenant', 'role', 'description']
 
 
-def vlan_status_choices():
-    status_counts = {}
-    for status in VLAN.objects.values('status').annotate(count=Count('status')).order_by('status'):
-        status_counts[status['status']] = status['count']
-    return [(s[0], '{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in VLAN_STATUS_CHOICES]
-
-
 class VLANFilterForm(BootstrapMixin, CustomFieldFilterForm):
     model = VLAN
     q = forms.CharField(required=False, label='Search')
@@ -903,7 +890,12 @@ class VLANFilterForm(BootstrapMixin, CustomFieldFilterForm):
         to_field_name='slug',
         null_label='-- None --'
     )
-    status = forms.MultipleChoiceField(choices=vlan_status_choices, required=False)
+    status = AnnotatedMultipleChoiceField(
+        choices=VLAN_STATUS_CHOICES,
+        annotate=VLAN.objects.all(),
+        annotate_field='status',
+        required=False
+    )
     role = FilterChoiceField(
         queryset=Role.objects.annotate(filter_count=Count('vlans')),
         to_field_name='slug',

+ 10 - 1
netbox/ipam/models.py

@@ -6,6 +6,7 @@ from django.contrib.contenttypes.fields import GenericRelation
 from django.core.exceptions import ValidationError
 from django.core.validators import MaxValueValidator, MinValueValidator
 from django.db import models
+from django.db.models import Q
 from django.db.models.expressions import RawSQL
 from django.urls import reverse
 from django.utils.encoding import python_2_unicode_compatible
@@ -365,7 +366,8 @@ class Prefix(CreatedUpdatedModel, CustomFieldModel):
             child_prefixes = netaddr.IPSet([p.prefix for p in queryset])
             return int(float(child_prefixes.size) / self.prefix.size * 100)
         else:
-            child_count = self.get_child_ips().count()
+            # Compile an IPSet to avoid counting duplicate IPs
+            child_count = netaddr.IPSet([ip.address.ip for ip in self.get_child_ips()]).size
             prefix_size = self.prefix.size
             if self.family == 4 and self.prefix.prefixlen < 31 and not self.is_pool:
                 prefix_size -= 2
@@ -615,6 +617,13 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel):
     def get_status_class(self):
         return STATUS_CHOICE_CLASSES[self.status]
 
+    def get_members(self):
+        # Return all interfaces assigned to this VLAN
+        return Interface.objects.filter(
+            Q(untagged_vlan_id=self.pk) |
+            Q(tagged_vlans=self.pk)
+        )
+
 
 @python_2_unicode_compatible
 class Service(CreatedUpdatedModel):

+ 31 - 0
netbox/ipam/tables.py

@@ -3,6 +3,7 @@ from __future__ import unicode_literals
 import django_tables2 as tables
 from django_tables2.utils import Accessor
 
+from dcim.models import Interface
 from tenancy.tables import COL_TENANT
 from utilities.tables import BaseTable, ToggleColumn
 from .models import Aggregate, IPAddress, Prefix, RIR, Role, VLAN, VLANGroup, VRF
@@ -138,6 +139,18 @@ VLANGROUP_ACTIONS = """
 {% endif %}
 """
 
+VLAN_MEMBER_UNTAGGED = """
+{% if record.untagged_vlan_id == vlan.pk %}
+    <i class="glyphicon glyphicon-ok">
+{% endif %}
+"""
+
+VLAN_MEMBER_ACTIONS = """
+{% if perms.dcim.change_interface %}
+    <a href="{% if record.device %}{% url 'dcim:interface_edit' pk=record.pk %}{% else %}{% url 'virtualization:interface_edit' pk=record.pk %}{% endif %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil"></i></a>
+{% endif %}
+"""
+
 TENANT_LINK = """
 {% if record.tenant %}
     <a href="{% url 'tenancy:tenant' slug=record.tenant.slug %}" title="{{ record.tenant.description }}">{{ record.tenant }}</a>
@@ -361,3 +374,21 @@ class VLANDetailTable(VLANTable):
 
     class Meta(VLANTable.Meta):
         fields = ('pk', 'vid', 'site', 'group', 'name', 'prefixes', 'tenant', 'status', 'role', 'description')
+
+
+class VLANMemberTable(BaseTable):
+    parent = tables.LinkColumn(order_by=['device', 'virtual_machine'])
+    name = tables.Column(verbose_name='Interface')
+    untagged = tables.TemplateColumn(
+        template_code=VLAN_MEMBER_UNTAGGED,
+        orderable=False
+    )
+    actions = tables.TemplateColumn(
+        template_code=VLAN_MEMBER_ACTIONS,
+        attrs={'td': {'class': 'text-right'}},
+        verbose_name=''
+    )
+
+    class Meta(BaseTable.Meta):
+        model = Interface
+        fields = ('parent', 'name', 'untagged', 'actions')

+ 1 - 0
netbox/ipam/urls.py

@@ -80,6 +80,7 @@ urlpatterns = [
     url(r'^vlans/edit/$', views.VLANBulkEditView.as_view(), name='vlan_bulk_edit'),
     url(r'^vlans/delete/$', views.VLANBulkDeleteView.as_view(), name='vlan_bulk_delete'),
     url(r'^vlans/(?P<pk>\d+)/$', views.VLANView.as_view(), name='vlan'),
+    url(r'^vlans/(?P<pk>\d+)/members/$', views.VLANMembersView.as_view(), name='vlan_members'),
     url(r'^vlans/(?P<pk>\d+)/edit/$', views.VLANEditView.as_view(), name='vlan_edit'),
     url(r'^vlans/(?P<pk>\d+)/delete/$', views.VLANDeleteView.as_view(), name='vlan_delete'),
 

+ 32 - 0
netbox/ipam/views.py

@@ -851,6 +851,38 @@ class VLANView(View):
         })
 
 
+class VLANMembersView(View):
+
+    def get(self, request, pk):
+
+        vlan = get_object_or_404(VLAN.objects.all(), pk=pk)
+        members = vlan.get_members().select_related('device', 'virtual_machine')
+
+        members_table = tables.VLANMemberTable(members)
+        # if request.user.has_perm('dcim.change_interface'):
+        #     members_table.columns.show('pk')
+
+        paginate = {
+            'klass': EnhancedPaginator,
+            'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT)
+        }
+        RequestConfig(request, paginate).configure(members_table)
+
+        # Compile permissions list for rendering the object table
+        # permissions = {
+        #     'add': request.user.has_perm('ipam.add_ipaddress'),
+        #     'change': request.user.has_perm('ipam.change_ipaddress'),
+        #     'delete': request.user.has_perm('ipam.delete_ipaddress'),
+        # }
+
+        return render(request, 'ipam/vlan_members.html', {
+            'vlan': vlan,
+            'members_table': members_table,
+            # 'permissions': permissions,
+            # 'bulk_querystring': 'vrf_id={}&parent={}'.format(prefix.vrf.pk if prefix.vrf else '0', prefix.prefix),
+        })
+
+
 class VLANCreateView(PermissionRequiredMixin, ObjectEditView):
     permission_required = 'ipam.add_vlan'
     model = VLAN

+ 28 - 2
netbox/netbox/settings.py

@@ -22,7 +22,7 @@ if sys.version_info[0] < 3:
         DeprecationWarning
     )
 
-VERSION = '2.3.1'
+VERSION = '2.3.2'
 
 BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
 
@@ -133,7 +133,6 @@ INSTALLED_APPS = (
     'django_tables2',
     'mptt',
     'rest_framework',
-    'rest_framework_swagger',
     'timezone_field',
     'circuits',
     'dcim',
@@ -144,6 +143,7 @@ INSTALLED_APPS = (
     'users',
     'utilities',
     'virtualization',
+    'drf_yasg',
 )
 
 # Middleware
@@ -246,6 +246,32 @@ REST_FRAMEWORK = {
     'VIEW_NAME_FUNCTION': 'netbox.api.get_view_name',
 }
 
+# drf_yasg settings for Swagger
+SWAGGER_SETTINGS = {
+    'DEFAULT_FIELD_INSPECTORS': [
+        'utilities.custom_inspectors.NullableBooleanFieldInspector',
+        'utilities.custom_inspectors.CustomChoiceFieldInspector',
+        'drf_yasg.inspectors.CamelCaseJSONFilter',
+        'drf_yasg.inspectors.ReferencingSerializerInspector',
+        'drf_yasg.inspectors.RelatedFieldInspector',
+        'drf_yasg.inspectors.ChoiceFieldInspector',
+        'drf_yasg.inspectors.FileFieldInspector',
+        'drf_yasg.inspectors.DictFieldInspector',
+        'drf_yasg.inspectors.SimpleFieldInspector',
+        'drf_yasg.inspectors.StringDefaultFieldInspector',
+    ],
+    'DEFAULT_FILTER_INSPECTORS': [
+        'utilities.custom_inspectors.IdInFilterInspector',
+        'drf_yasg.inspectors.CoreAPICompatInspector',
+    ],
+    'DEFAULT_PAGINATOR_INSPECTORS': [
+        'utilities.custom_inspectors.NullablePaginatorInspector',
+        'drf_yasg.inspectors.DjangoRestResponsePagination',
+        'drf_yasg.inspectors.CoreAPICompatInspector',
+    ]
+}
+
+
 # Django debug toolbar
 INTERNAL_IPS = (
     '127.0.0.1',

+ 17 - 3
netbox/netbox/urls.py

@@ -4,12 +4,24 @@ from django.conf import settings
 from django.conf.urls import include, url
 from django.contrib import admin
 from django.views.static import serve
-from rest_framework_swagger.views import get_swagger_view
+from drf_yasg.views import get_schema_view
+from drf_yasg import openapi
 
 from netbox.views import APIRootView, HomeView, SearchView
 from users.views import LoginView, LogoutView
 
-swagger_view = get_swagger_view(title='NetBox API')
+schema_view = get_schema_view(
+    openapi.Info(
+        title="NetBox API",
+        default_version='v2',
+        description="API to access NetBox",
+        terms_of_service="https://github.com/digitalocean/netbox",
+        contact=openapi.Contact(email="netbox@digitalocean.com"),
+        license=openapi.License(name="Apache v2 License"),
+    ),
+    validators=['flex', 'ssv'],
+    public=True,
+)
 
 _patterns = [
 
@@ -40,7 +52,9 @@ _patterns = [
     url(r'^api/secrets/', include('secrets.api.urls')),
     url(r'^api/tenancy/', include('tenancy.api.urls')),
     url(r'^api/virtualization/', include('virtualization.api.urls')),
-    url(r'^api/docs/', swagger_view, name='api_docs'),
+    url(r'^api/docs/$', schema_view.with_ui('swagger', cache_timeout=None), name='api_docs'),
+    url(r'^api/redoc/$', schema_view.with_ui('redoc', cache_timeout=None), name='api_redocs'),
+    url(r'^api/swagger(?P<format>.json|.yaml)$', schema_view.without_ui(cache_timeout=None), name='schema_swagger'),
 
     # Serving static media in Django to pipe it through LoginRequiredMiddleware
     url(r'^media/(?P<path>.*)$', serve, {'document_root': settings.MEDIA_ROOT}),

+ 21 - 4
netbox/secrets/forms.py

@@ -58,17 +58,34 @@ class SecretRoleCSVForm(forms.ModelForm):
 #
 
 class SecretForm(BootstrapMixin, forms.ModelForm):
-    plaintext = forms.CharField(max_length=65535, required=False, label='Plaintext',
-                                widget=forms.PasswordInput(attrs={'class': 'requires-session-key'}))
-    plaintext2 = forms.CharField(max_length=65535, required=False, label='Plaintext (verify)',
-                                 widget=forms.PasswordInput())
+    plaintext = forms.CharField(
+        max_length=65535,
+        required=False,
+        label='Plaintext',
+        widget=forms.PasswordInput(attrs={'class': 'requires-session-key'})
+    )
+    plaintext2 = forms.CharField(
+        max_length=65535,
+        required=False,
+        label='Plaintext (verify)',
+        widget=forms.PasswordInput()
+    )
 
     class Meta:
         model = Secret
         fields = ['role', 'name', 'plaintext', 'plaintext2']
 
+    def __init__(self, *args, **kwargs):
+
+        super(SecretForm, self).__init__(*args, **kwargs)
+
+        # A plaintext value is required when creating a new Secret
+        if not self.instance.pk:
+            self.fields['plaintext'].required = True
+
     def clean(self):
 
+        # Verify that the provided plaintext values match
         if self.cleaned_data['plaintext'] != self.cleaned_data['plaintext2']:
             raise forms.ValidationError({
                 'plaintext2': "The two given plaintext values do not match. Please check your input."

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

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

@@ -13,16 +13,44 @@
             {% 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>
+    {% 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 '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
+                </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 'dcim: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 %}

+ 46 - 0
netbox/templates/ipam/inc/vlan_header.html

@@ -0,0 +1,46 @@
+<div class="row">
+    <div class="col-sm-8 col-md-9">
+        <ol class="breadcrumb">
+            <li><a href="{% url 'ipam:vlan_list' %}">VLANs</a></li>
+            {% if vlan.site %}
+                <li><a href="{% url 'ipam:vlan_list' %}?site={{ vlan.site.slug }}">{{ vlan.site }}</a></li>
+            {% endif %}
+            {% if vlan.group %}
+                <li><a href="{% url 'ipam:vlan_list' %}?group={{ vlan.group.slug }}">{{ vlan.group }}</a></li>
+            {% endif %}
+            <li>{{ vlan }}</li>
+        </ol>
+    </div>
+    <div class="col-sm-4 col-md-3">
+    <form action="{% url 'ipam:vlan_list' %}" method="get">
+        <div class="input-group">
+            <input type="text" name="q" class="form-control" placeholder="Search VLANs" />
+            <span class="input-group-btn">
+                <button type="submit" class="btn btn-primary">
+                    <span class="fa fa-search" aria-hidden="true"></span>
+                </button>
+            </span>
+        </div>
+    </form>
+    </div>
+</div>
+<div class="pull-right">
+    {% if perms.ipam.change_vlan %}
+        <a href="{% url 'ipam:vlan_edit' pk=vlan.pk %}" class="btn btn-warning">
+            <span class="fa fa-pencil" aria-hidden="true"></span>
+            Edit this VLAN
+        </a>
+    {% endif %}
+    {% if perms.ipam.delete_vlan %}
+        <a href="{% url 'ipam:vlan_delete' pk=vlan.pk %}" class="btn btn-danger">
+            <span class="fa fa-trash" aria-hidden="true"></span>
+            Delete this VLAN
+        </a>
+    {% endif %}
+</div>
+<h1>{% block title %}VLAN {{ vlan.display_name }}{% endblock %}</h1>
+{% include 'inc/created_updated.html' with obj=vlan %}
+<ul class="nav nav-tabs" style="margin-bottom: 20px">
+    <li role="presentation"{% if active_tab == 'vlan' %} class="active"{% endif %}><a href="{% url 'ipam:vlan' pk=vlan.pk %}">VLAN</a></li>
+    <li role="presentation"{% if active_tab == 'members' %} class="active"{% endif %}><a href="{% url 'ipam:vlan_members' pk=vlan.pk %}">Members <span class="badge">{{ vlan.get_members.count }}</span></a></li>
+</ul>

+ 1 - 42
netbox/templates/ipam/vlan.html

@@ -1,48 +1,7 @@
 {% extends '_base.html' %}
 
 {% block content %}
-<div class="row">
-    <div class="col-sm-8 col-md-9">
-        <ol class="breadcrumb">
-            <li><a href="{% url 'ipam:vlan_list' %}">VLANs</a></li>
-            {% if vlan.site %}
-                <li><a href="{% url 'ipam:vlan_list' %}?site={{ vlan.site.slug }}">{{ vlan.site }}</a></li>
-            {% endif %}
-            {% if vlan.group %}
-                <li><a href="{% url 'ipam:vlan_list' %}?group={{ vlan.group.slug }}">{{ vlan.group }}</a></li>
-            {% endif %}
-            <li>{{ vlan }}</li>
-        </ol>
-    </div>
-    <div class="col-sm-4 col-md-3">
-    <form action="{% url 'ipam:vlan_list' %}" method="get">
-        <div class="input-group">
-            <input type="text" name="q" class="form-control" placeholder="Search VLANs" />
-            <span class="input-group-btn">
-                <button type="submit" class="btn btn-primary">
-                    <span class="fa fa-search" aria-hidden="true"></span>
-                </button>
-            </span>
-        </div>
-    </form>
-    </div>
-</div>
-<div class="pull-right">
-    {% if perms.ipam.change_vlan %}
-        <a href="{% url 'ipam:vlan_edit' pk=vlan.pk %}" class="btn btn-warning">
-            <span class="fa fa-pencil" aria-hidden="true"></span>
-            Edit this VLAN
-        </a>
-    {% endif %}
-    {% if perms.ipam.delete_vlan %}
-        <a href="{% url 'ipam:vlan_delete' pk=vlan.pk %}" class="btn btn-danger">
-            <span class="fa fa-trash" aria-hidden="true"></span>
-            Delete this VLAN
-        </a>
-    {% endif %}
-</div>
-<h1>{% block title %}VLAN {{ vlan.display_name }}{% endblock %}</h1>
-{% include 'inc/created_updated.html' with obj=vlan %}
+{% include 'ipam/inc/vlan_header.html' with active_tab='vlan' %}
 <div class="row">
 	<div class="col-md-6">
         <div class="panel panel-default">

+ 12 - 0
netbox/templates/ipam/vlan_members.html

@@ -0,0 +1,12 @@
+{% extends '_base.html' %}
+
+{% block title %}{{ vlan }} - Members{% endblock %}
+
+{% block content %}
+    {% include 'ipam/inc/vlan_header.html' with active_tab='members' %}
+    <div class="row">
+        <div class="col-md-12">
+            {% include 'utilities/obj_table.html' with table=members_table table_template='panel_table.html' heading='VLAN Members' parent=vlan %}
+        </div>
+    </div>
+{% endblock %}

+ 9 - 7
netbox/templates/utilities/obj_edit.html

@@ -31,13 +31,15 @@
         </div>
         <div class="row">
             <div class="col-md-6 col-md-offset-3 text-right">
-                {% if obj.pk %}
-                    <button type="submit" name="_update" class="btn btn-primary">Update</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>
+                {% block buttons %}
+                    {% if obj.pk %}
+                        <button type="submit" name="_update" class="btn btn-primary">Update</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 %}
             </div>
         </div>
     </form>

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

+ 76 - 0
netbox/utilities/custom_inspectors.py

@@ -0,0 +1,76 @@
+from drf_yasg import openapi
+from drf_yasg.inspectors import FieldInspector, NotHandled, PaginatorInspector, FilterInspector
+from rest_framework.fields import ChoiceField
+
+from extras.api.customfields import CustomFieldsSerializer
+from utilities.api import ChoiceFieldSerializer
+
+
+class CustomChoiceFieldInspector(FieldInspector):
+    def field_to_swagger_object(self, field, swagger_object_type, use_references, **kwargs):
+        # this returns a callable which extracts title, description and other stuff
+        # https://drf-yasg.readthedocs.io/en/stable/_modules/drf_yasg/inspectors/base.html#FieldInspector._get_partial_types
+        SwaggerType, _ = self._get_partial_types(field, swagger_object_type, use_references, **kwargs)
+
+        if isinstance(field, ChoiceFieldSerializer):
+            value_schema = openapi.Schema(type=openapi.TYPE_INTEGER)
+
+            choices = list(field._choices.keys())
+            if set([None] + choices) == {None, True, False}:
+                # DeviceType.subdevice_role, Device.face and InterfaceConnection.connection_status all need to be
+                # differentiated since they each have subtly different values in their choice keys.
+                # - subdevice_role and connection_status are booleans, although subdevice_role includes None
+                # - face is an integer set {0, 1} which is easily confused with {False, True}
+                schema_type = openapi.TYPE_INTEGER
+                if all(type(x) == bool for x in [c for c in choices if c is not None]):
+                    schema_type = openapi.TYPE_BOOLEAN
+                value_schema = openapi.Schema(type=schema_type)
+                value_schema['x-nullable'] = True
+
+            schema = SwaggerType(type=openapi.TYPE_OBJECT, required=["label", "value"], properties={
+                "label": openapi.Schema(type=openapi.TYPE_STRING),
+                "value": value_schema
+            })
+
+            return schema
+
+        elif isinstance(field, CustomFieldsSerializer):
+            schema = SwaggerType(type=openapi.TYPE_OBJECT)
+            return schema
+
+        return NotHandled
+
+
+class NullableBooleanFieldInspector(FieldInspector):
+    def process_result(self, result, method_name, obj, **kwargs):
+
+        if isinstance(result, openapi.Schema) and isinstance(obj, ChoiceField) and result.type == 'boolean':
+            keys = obj.choices.keys()
+            if set(keys) == {None, True, False}:
+                result['x-nullable'] = True
+                result.type = 'boolean'
+
+        return result
+
+
+class IdInFilterInspector(FilterInspector):
+    def process_result(self, result, method_name, obj, **kwargs):
+        if isinstance(result, list):
+            params = [p for p in result if isinstance(p, openapi.Parameter) and p.name == 'id__in']
+            for p in params:
+                p.type = 'string'
+
+        return result
+
+
+class NullablePaginatorInspector(PaginatorInspector):
+    def process_result(self, result, method_name, obj, **kwargs):
+        if method_name == 'get_paginated_response' and isinstance(result, openapi.Schema):
+            next = result.properties['next']
+            if isinstance(next, openapi.Schema):
+                next['x-nullable'] = True
+            previous = result.properties['previous']
+            if isinstance(previous, openapi.Schema):
+                previous['x-nullable'] = True
+
+        return result

+ 80 - 4
netbox/utilities/forms.py

@@ -6,6 +6,7 @@ import re
 
 from django import forms
 from django.conf import settings
+from django.db.models import Count
 from django.urls import reverse_lazy
 from mptt.forms import TreeNodeMultipleChoiceField
 
@@ -38,6 +39,7 @@ COLOR_CHOICES = (
     ('111111', 'Black'),
 )
 NUMERIC_EXPANSION_PATTERN = '\[((?:\d+[?:,-])+\d+)\]'
+ALPHANUMERIC_EXPANSION_PATTERN = '\[((?:[a-zA-Z0-9]+[?:,-])+[a-zA-Z0-9]+)\]'
 IP4_EXPANSION_PATTERN = '\[((?:[0-9]{1,3}[?:,-])+[0-9]{1,3})\]'
 IP6_EXPANSION_PATTERN = '\[((?:[0-9a-f]{1,4}[?:,-])+[0-9a-f]{1,4})\]'
 
@@ -76,6 +78,45 @@ def expand_numeric_pattern(string):
             yield "{}{}{}".format(lead, i, remnant)
 
 
+def parse_alphanumeric_range(string):
+    """
+    Expand an alphanumeric range (continuous or not) into a list.
+    'a-d,f' => [a, b, c, d, f]
+    '0-3,a-d' => [0, 1, 2, 3, a, b, c, d]
+    """
+    values = []
+    for dash_range in string.split(','):
+        try:
+            begin, end = dash_range.split('-')
+            vals = begin + end
+            # Break out of loop if there's an invalid pattern to return an error
+            if (not (vals.isdigit() or vals.isalpha())) or (vals.isalpha() and not (vals.isupper() or vals.islower())):
+                return []
+        except ValueError:
+            begin, end = dash_range, dash_range
+        if begin.isdigit() and end.isdigit():
+            for n in list(range(int(begin), int(end) + 1)):
+                values.append(n)
+        else:
+            for n in list(range(ord(begin), ord(end) + 1)):
+                values.append(chr(n))
+    return values
+
+
+def expand_alphanumeric_pattern(string):
+    """
+    Expand an alphabetic pattern into a list of strings.
+    """
+    lead, pattern, remnant = re.split(ALPHANUMERIC_EXPANSION_PATTERN, string, maxsplit=1)
+    parsed_range = parse_alphanumeric_range(pattern)
+    for i in parsed_range:
+        if re.search(ALPHANUMERIC_EXPANSION_PATTERN, remnant):
+            for string in expand_alphanumeric_pattern(remnant):
+                yield "{}{}{}".format(lead, i, string)
+        else:
+            yield "{}{}{}".format(lead, i, remnant)
+
+
 def expand_ipaddress_pattern(string, family):
     """
     Expand an IP address pattern into a list of strings. Examples:
@@ -305,12 +346,15 @@ class ExpandableNameField(forms.CharField):
     def __init__(self, *args, **kwargs):
         super(ExpandableNameField, self).__init__(*args, **kwargs)
         if not self.help_text:
-            self.help_text = 'Numeric ranges are supported for bulk creation.<br />'\
-                             'Example: <code>ge-0/0/[0-23,25,30]</code>'
+            self.help_text = 'Alphanumeric ranges are supported for bulk creation.<br />' \
+                             'Mixed cases and types within a single range are not supported.<br />' \
+                             'Examples:<ul><li><code>ge-0/0/[0-23,25,30]</code></li>' \
+                             '<li><code>e[0-3][a-d,f]</code></li>' \
+                             '<li><code>e[0-3,a-d,f]</code></li></ul>'
 
     def to_python(self, value):
-        if re.search(NUMERIC_EXPANSION_PATTERN, value):
-            return list(expand_numeric_pattern(value))
+        if re.search(ALPHANUMERIC_EXPANSION_PATTERN, value):
+            return list(expand_alphanumeric_pattern(value))
         return [value]
 
 
@@ -450,6 +494,38 @@ class FilterTreeNodeMultipleChoiceField(FilterChoiceFieldMixin, TreeNodeMultiple
     pass
 
 
+class AnnotatedMultipleChoiceField(forms.MultipleChoiceField):
+    """
+    Render a set of static choices with each choice annotated to include a count of related objects. For example, this
+    field can be used to display a list of all available device statuses along with the number of devices currently
+    assigned to each status.
+    """
+
+    def annotate_choices(self):
+        queryset = self.annotate.values(
+            self.annotate_field
+        ).annotate(
+            count=Count(self.annotate_field)
+        ).order_by(
+            self.annotate_field
+        )
+        choice_counts = {
+            c[self.annotate_field]: c['count'] for c in queryset
+        }
+        annotated_choices = [
+            (c[0], '{} ({})'.format(c[1], choice_counts.get(c[0], 0))) for c in self.static_choices
+        ]
+
+        return annotated_choices
+
+    def __init__(self, choices, annotate, annotate_field, *args, **kwargs):
+        self.annotate = annotate
+        self.annotate_field = annotate_field
+        self.static_choices = choices
+
+        super(AnnotatedMultipleChoiceField, self).__init__(choices=self.annotate_choices, *args, **kwargs)
+
+
 class LaxURLField(forms.URLField):
     """
     Modifies Django's built-in URLField in two ways:

+ 37 - 12
netbox/virtualization/forms.py

@@ -5,7 +5,8 @@ from django.core.exceptions import ValidationError
 from django.db.models import Count
 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.models import Device, DeviceRole, Interface, Platform, Rack, Region, Site
 from extras.forms import CustomFieldBulkEditForm, CustomFieldForm, CustomFieldFilterForm
@@ -13,9 +14,9 @@ from ipam.models import IPAddress
 from tenancy.forms import TenancyForm
 from tenancy.models import Tenant
 from utilities.forms import (
-    add_blank_choice, APISelect, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect,
+    AnnotatedMultipleChoiceField, APISelect, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect,
     ChainedFieldsMixin, ChainedModelChoiceField, ChainedModelMultipleChoiceField, CommentField, ComponentForm,
-    ConfirmationForm, CSVChoiceField, ExpandableNameField, FilterChoiceField, SlugField, SmallTextarea,
+    ConfirmationForm, CSVChoiceField, ExpandableNameField, FilterChoiceField, SlugField, SmallTextarea, add_blank_choice
 )
 from .constants import VM_STATUS_CHOICES
 from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
@@ -361,13 +362,6 @@ class VirtualMachineBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
         nullable_fields = ['role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments']
 
 
-def vm_status_choices():
-    status_counts = {}
-    for status in VirtualMachine.objects.values('status').annotate(count=Count('status')).order_by('status'):
-        status_counts[status['status']] = status['count']
-    return [(s[0], '{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in VM_STATUS_CHOICES]
-
-
 class VirtualMachineFilterForm(BootstrapMixin, CustomFieldFilterForm):
     model = VirtualMachine
     q = forms.CharField(required=False, label='Search')
@@ -395,7 +389,12 @@ class VirtualMachineFilterForm(BootstrapMixin, CustomFieldFilterForm):
         to_field_name='slug',
         null_label='-- None --'
     )
-    status = forms.MultipleChoiceField(choices=vm_status_choices, required=False)
+    status = AnnotatedMultipleChoiceField(
+        choices=VM_STATUS_CHOICES,
+        annotate=VirtualMachine.objects.all(),
+        annotate_field='status',
+        required=False
+    )
     tenant = FilterChoiceField(
         queryset=Tenant.objects.annotate(filter_count=Count('virtual_machines')),
         to_field_name='slug',
@@ -416,11 +415,37 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm):
 
     class Meta:
         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 = {
             'virtual_machine': 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):

+ 1 - 2
netbox/virtualization/models.py

@@ -283,7 +283,6 @@ class VirtualMachine(CreatedUpdatedModel, CustomFieldModel):
         else:
             return None
 
+    @property
     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

+ 1 - 0
netbox/virtualization/views.py

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

+ 1 - 0
old_requirements.txt

@@ -1,2 +1,3 @@
+django-rest-swagger
 psycopg2
 pycrypto

+ 1 - 1
requirements.txt

@@ -3,10 +3,10 @@ django-cors-headers>=2.1.0
 django-debug-toolbar>=1.9.0
 django-filter>=1.1.0
 django-mptt>=0.9.0
-django-rest-swagger>=2.1.0
 django-tables2>=1.19.0
 django-timezone-field>=2.0
 djangorestframework>=3.7.7
+drf-yasg[validation]>=1.4.4
 graphviz>=0.8.2
 Markdown>=2.6.11
 natsort>=5.2.0