Browse Source

Merge pull request #1987 from digitalocean/develop

Release v2.3.2
Jeremy Stretch 7 years ago
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/).
 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),
 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
 ### Build Status
 
 
@@ -41,3 +41,4 @@ and run `upgrade.sh`.
 
 
 * [Docker container](https://github.com/ninech/netbox-docker) (via [@cimnine](https://github.com/cimnine))
 * [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))
 * [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 !
         ProxyPass !
     </Location>
     </Location>
 
 
+    RequestHeader set "X-Forwarded-Proto" expr=%{REQUEST_SCHEME}
     ProxyPass / http://127.0.0.1:8001/
     ProxyPass / http://127.0.0.1:8001/
     ProxyPassReverse / http://127.0.0.1:8001/
     ProxyPassReverse / http://127.0.0.1:8001/
 </VirtualHost>
 </VirtualHost>
@@ -92,6 +93,7 @@ Save the contents of the above example in `/etc/apache2/sites-available/netbox.c
 ```no-highlight
 ```no-highlight
 # a2enmod proxy
 # a2enmod proxy
 # a2enmod proxy_http
 # a2enmod proxy_http
+# a2enmod headers
 # a2ensite netbox
 # a2ensite netbox
 # service apache2 restart
 # 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.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, 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 .constants import CIRCUIT_STATUS_CHOICES
 from .models import Circuit, CircuitTermination, CircuitType, Provider
 from .models import Circuit, CircuitTermination, CircuitType, Provider
@@ -169,13 +169,6 @@ class CircuitBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
         nullable_fields = ['tenant', 'commit_rate', 'description', 'comments']
         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):
 class CircuitFilterForm(BootstrapMixin, CustomFieldFilterForm):
     model = Circuit
     model = Circuit
     q = forms.CharField(required=False, label='Search')
     q = forms.CharField(required=False, label='Search')
@@ -187,7 +180,12 @@ class CircuitFilterForm(BootstrapMixin, CustomFieldFilterForm):
         queryset=Provider.objects.annotate(filter_count=Count('circuits')),
         queryset=Provider.objects.annotate(filter_count=Count('circuits')),
         to_field_name='slug'
         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(
     tenant = FilterChoiceField(
         queryset=Tenant.objects.annotate(filter_count=Count('circuits')),
         queryset=Tenant.objects.annotate(filter_count=Count('circuits')),
         to_field_name='slug',
         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.db import transaction
 from django.http import HttpResponseBadRequest, HttpResponseForbidden
 from django.http import HttpResponseBadRequest, HttpResponseForbidden
 from django.shortcuts import get_object_or_404
 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.decorators import detail_route
 from rest_framework.mixins import ListModelMixin
 from rest_framework.mixins import ListModelMixin
 from rest_framework.response import Response
 from rest_framework.response import Response
@@ -418,14 +421,20 @@ class ConnectedDeviceViewSet(ViewSet):
     * `peer-interface`: The name of the peer interface
     * `peer-interface`: The name of the peer interface
     """
     """
     permission_classes = [IsAuthenticatedOrLoginNotRequired]
     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):
     def get_view_name(self):
         return "Connected Device Locator"
         return "Connected Device Locator"
 
 
+    @swagger_auto_schema(
+        manual_parameters=[_device_param, _interface_param], responses={'200': serializers.DeviceSerializer})
     def list(self, request):
     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:
         if not peer_device_name or not peer_interface_name:
             raise MissingFilterException(detail='Request must include "peer-device" and "peer-interface" filters.')
             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.forms import TenancyForm
 from tenancy.models import Tenant
 from tenancy.models import Tenant
 from utilities.forms import (
 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 virtualization.models import Cluster
 from .constants import (
 from .constants import (
@@ -37,6 +36,12 @@ from .models import (
 
 
 DEVICE_BY_PK_RE = '{\d+\}'
 DEVICE_BY_PK_RE = '{\d+\}'
 
 
+INTERFACE_MODE_HELP_TEXT = """
+Access: One untagged VLAN<br />
+Tagged: One untagged VLAN and/or one or more tagged VLANs<br />
+Tagged All: Implies all VLANs are available (w/optional untagged VLAN)
+"""
+
 
 
 def get_device_by_name_or_pk(name):
 def get_device_by_name_or_pk(name):
     """
     """
@@ -172,17 +177,15 @@ class SiteBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
         nullable_fields = ['region', 'tenant', 'asn', 'description', 'time_zone']
         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):
 class SiteFilterForm(BootstrapMixin, CustomFieldFilterForm):
     model = Site
     model = Site
     q = forms.CharField(required=False, label='Search')
     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(
     region = FilterTreeNodeMultipleChoiceField(
         queryset=Region.objects.annotate(filter_count=Count('sites')),
         queryset=Region.objects.annotate(filter_count=Count('sites')),
         to_field_name='slug',
         to_field_name='slug',
@@ -700,13 +703,21 @@ class PlatformForm(BootstrapMixin, forms.ModelForm):
 
 
 class PlatformCSVForm(forms.ModelForm):
 class PlatformCSVForm(forms.ModelForm):
     slug = SlugField()
     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:
     class Meta:
         model = Platform
         model = Platform
         fields = Platform.csv_headers
         fields = Platform.csv_headers
         help_texts = {
         help_texts = {
             'name': 'Platform name',
             'name': 'Platform name',
-            'manufacturer': 'Manufacturer name',
         }
         }
 
 
 
 
@@ -1040,13 +1051,6 @@ class DeviceBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
         nullable_fields = ['tenant', 'platform', 'serial']
         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):
 class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm):
     model = Device
     model = Device
     q = forms.CharField(required=False, label='Search')
     q = forms.CharField(required=False, label='Search')
@@ -1084,7 +1088,12 @@ class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm):
         to_field_name='slug',
         to_field_name='slug',
         null_label='-- None --',
         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')
     mac_address = forms.CharField(required=False, label='MAC address')
     has_primary_ip = forms.NullBooleanField(
     has_primary_ip = forms.NullBooleanField(
         required=False,
         required=False,
@@ -1648,63 +1657,23 @@ class PowerOutletBulkDisconnectForm(ConfirmationForm):
 # Interfaces
 # 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:
     class Meta:
         model = Interface
         model = Interface
         fields = [
         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 = {
         widgets = {
             'device': forms.HiddenInput(),
             'device': forms.HiddenInput(),
         }
         }
+        labels = {
+            'mode': '802.1Q Mode',
+        }
+        help_texts = {
+            'mode': INTERFACE_MODE_HELP_TEXT,
+        }
 
 
     def __init__(self, *args, **kwargs):
     def __init__(self, *args, **kwargs):
         super(InterfaceForm, self).__init__(*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
                 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')
     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)
@@ -1786,50 +1805,6 @@ class InterfaceCreateForm(ComponentForm, ChainedFieldsMixin):
     )
     )
     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)
     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):
     def __init__(self, *args, **kwargs):
 
 
@@ -1847,41 +1822,8 @@ class InterfaceCreateForm(ComponentForm, ChainedFieldsMixin):
         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 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)
     pk = forms.ModelMultipleChoiceField(queryset=Interface.objects.all(), widget=forms.MultipleHiddenInput)
     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)
     enabled = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect)
     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')
     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)
     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', 'untagged_vlan', 'tagged_vlans']
+        nullable_fields = ['lag', 'mtu', 'description', 'mode']
 
 
     def __init__(self, *args, **kwargs):
     def __init__(self, *args, **kwargs):
         super(InterfaceBulkEditForm, self).__init__(*args, **kwargs)
         super(InterfaceBulkEditForm, self).__init__(*args, **kwargs)
@@ -1951,28 +1849,6 @@ class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm, ChainedFieldsMixin):
         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
-
-        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):
 class InterfaceBulkRenameForm(BulkRenameForm):
     pk = forms.ModelMultipleChoiceField(queryset=Interface.objects.all(), widget=forms.MultipleHiddenInput)
     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)
                                  "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
     @property
     def parent(self):
     def parent(self):
         return self.device or self.virtual_machine
         return self.device or self.virtual_machine

+ 38 - 10
netbox/dcim/tables.py

@@ -47,8 +47,13 @@ REGION_ACTIONS = """
 """
 """
 
 
 RACKGROUP_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 %}
 {% 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 %}
 {% endif %}
 """
 """
 
 
@@ -128,6 +133,10 @@ SUBDEVICE_ROLE_TEMPLATE = """
 {% if record.subdevice_role == True %}Parent{% elif record.subdevice_role == False %}Child{% else %}&mdash;{% endif %}
 {% 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 = """
 UTILIZATION_GRAPH = """
 {% load helpers %}
 {% load helpers %}
 {% utilization_graph value %}
 {% utilization_graph value %}
@@ -182,12 +191,21 @@ class SiteTable(BaseTable):
 
 
 class RackGroupTable(BaseTable):
 class RackGroupTable(BaseTable):
     pk = ToggleColumn()
     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):
     class Meta(BaseTable.Meta):
         model = RackGroup
         model = RackGroup
@@ -299,13 +317,23 @@ class ManufacturerTable(BaseTable):
 
 
 class DeviceTypeTable(BaseTable):
 class DeviceTypeTable(BaseTable):
     pk = ToggleColumn()
     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_full_depth = tables.BooleanColumn(verbose_name='Full Depth')
     is_console_server = tables.BooleanColumn(verbose_name='CS')
     is_console_server = tables.BooleanColumn(verbose_name='CS')
     is_pdu = tables.BooleanColumn(verbose_name='PDU')
     is_pdu = tables.BooleanColumn(verbose_name='PDU')
     is_network_device = tables.BooleanColumn(verbose_name='Net')
     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):
     class Meta(BaseTable.Meta):
         model = DeviceType
         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 import status
 from rest_framework.test import APITestCase
 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 (
 from dcim.models import (
     ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
     ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
     DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer,
     DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer,
@@ -2319,6 +2321,7 @@ class InterfaceTest(HttpStatusMixin, APITestCase):
         data = {
         data = {
             'device': self.device.pk,
             'device': self.device.pk,
             'name': 'Test Interface 4',
             'name': 'Test Interface 4',
+            'mode': IFACE_MODE_TAGGED,
             'tagged_vlans': [self.vlan1.id, self.vlan2.id],
             'tagged_vlans': [self.vlan1.id, self.vlan2.id],
             'untagged_vlan': self.vlan3.id
             'untagged_vlan': self.vlan3.id
         }
         }
@@ -2366,18 +2369,21 @@ class InterfaceTest(HttpStatusMixin, APITestCase):
             {
             {
                 'device': self.device.pk,
                 'device': self.device.pk,
                 'name': 'Test Interface 4',
                 'name': 'Test Interface 4',
+                'mode': IFACE_MODE_TAGGED,
                 'tagged_vlans': [self.vlan1.id],
                 'tagged_vlans': [self.vlan1.id],
                 'untagged_vlan': self.vlan2.id,
                 'untagged_vlan': self.vlan2.id,
             },
             },
             {
             {
                 'device': self.device.pk,
                 'device': self.device.pk,
                 'name': 'Test Interface 5',
                 'name': 'Test Interface 5',
+                'mode': IFACE_MODE_TAGGED,
                 'tagged_vlans': [self.vlan1.id],
                 'tagged_vlans': [self.vlan1.id],
                 'untagged_vlan': self.vlan2.id,
                 'untagged_vlan': self.vlan2.id,
             },
             },
             {
             {
                 'device': self.device.pk,
                 'device': self.device.pk,
                 'name': 'Test Interface 6',
                 'name': 'Test Interface 6',
+                'mode': IFACE_MODE_TAGGED,
                 'tagged_vlans': [self.vlan1.id],
                 'tagged_vlans': [self.vlan1.id],
                 'untagged_vlan': self.vlan2.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'^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'^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+)/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/(?P<pk>\d+)/delete/$', views.InterfaceDeleteView.as_view(), name='interface_delete'),
     url(r'^interfaces/rename/$', views.InterfaceBulkRenameView.as_view(), name='interface_bulk_rename'),
     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):
     def get(self, request, pk):
 
 
         device = get_object_or_404(Device, pk=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
             device.device_type.interface_ordering
-        ).connectable().filter(
-            device=device
-        ).select_related(
+        ).connectable().select_related(
             'connected_as_a', 'connected_as_b'
             'connected_as_a', 'connected_as_b'
         )
         )
 
 
@@ -1645,6 +1643,12 @@ class InterfaceEditView(PermissionRequiredMixin, ObjectEditView):
     template_name = 'dcim/interface_edit.html'
     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):
 class InterfaceDeleteView(PermissionRequiredMixin, ObjectDeleteView):
     permission_required = 'dcim.delete_interface'
     permission_required = 'dcim.delete_interface'
     model = Interface
     model = Interface
@@ -2226,7 +2230,7 @@ class VirtualChassisAddMemberView(PermissionRequiredMixin, GetReturnURLMixin, Vi
             device = member_select_form.cleaned_data['device']
             device = member_select_form.cleaned_data['device']
             device.virtual_chassis = virtual_chassis
             device.virtual_chassis = virtual_chassis
             data = {k: request.POST[k] for k in ['vc_position', 'vc_priority']}
             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():
             if membership_form.is_valid():
 
 
@@ -2242,7 +2246,7 @@ class VirtualChassisAddMemberView(PermissionRequiredMixin, GetReturnURLMixin, Vi
 
 
         else:
         else:
 
 
-            membership_form = forms.DeviceVCMembershipForm(request.POST)
+            membership_form = forms.DeviceVCMembershipForm(data=request.POST)
 
 
         return render(request, 'dcim/virtualchassis_add_member.html', {
         return render(request, 'dcim/virtualchassis_add_member.html', {
             'virtual_chassis': virtual_chassis,
             '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
         Convert a string into the object it represents depending on the type of field
         """
         """
-        if serialized_value is '':
+        if serialized_value == '':
             return None
             return None
         if self.type == CF_TYPE_INTEGER:
         if self.type == CF_TYPE_INTEGER:
             return int(serialized_value)
             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.forms import TenancyForm
 from tenancy.models import Tenant
 from tenancy.models import Tenant
 from utilities.forms import (
 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 virtualization.models import VirtualMachine
 from .constants import IPADDRESS_ROLE_CHOICES, IPADDRESS_STATUS_CHOICES, PREFIX_STATUS_CHOICES, VLAN_STATUS_CHOICES
 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']
         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):
 class PrefixFilterForm(BootstrapMixin, CustomFieldFilterForm):
     model = Prefix
     model = Prefix
     q = forms.CharField(required=False, label='Search')
     q = forms.CharField(required=False, label='Search')
@@ -376,7 +369,12 @@ class PrefixFilterForm(BootstrapMixin, CustomFieldFilterForm):
         to_field_name='slug',
         to_field_name='slug',
         null_label='-- None --'
         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(
     site = FilterChoiceField(
         queryset=Site.objects.annotate(filter_count=Count('prefixes')),
         queryset=Site.objects.annotate(filter_count=Count('prefixes')),
         to_field_name='slug',
         to_field_name='slug',
@@ -688,20 +686,6 @@ class IPAddressAssignForm(BootstrapMixin, forms.Form):
     address = forms.CharField(label='IP Address')
     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):
 class IPAddressFilterForm(BootstrapMixin, CustomFieldFilterForm):
     model = IPAddress
     model = IPAddress
     q = forms.CharField(required=False, label='Search')
     q = forms.CharField(required=False, label='Search')
@@ -721,8 +705,18 @@ class IPAddressFilterForm(BootstrapMixin, CustomFieldFilterForm):
         to_field_name='slug',
         to_field_name='slug',
         null_label='-- None --'
         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']
         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):
 class VLANFilterForm(BootstrapMixin, CustomFieldFilterForm):
     model = VLAN
     model = VLAN
     q = forms.CharField(required=False, label='Search')
     q = forms.CharField(required=False, label='Search')
@@ -903,7 +890,12 @@ class VLANFilterForm(BootstrapMixin, CustomFieldFilterForm):
         to_field_name='slug',
         to_field_name='slug',
         null_label='-- None --'
         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(
     role = FilterChoiceField(
         queryset=Role.objects.annotate(filter_count=Count('vlans')),
         queryset=Role.objects.annotate(filter_count=Count('vlans')),
         to_field_name='slug',
         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.exceptions import ValidationError
 from django.core.validators import MaxValueValidator, MinValueValidator
 from django.core.validators import MaxValueValidator, MinValueValidator
 from django.db import models
 from django.db import models
+from django.db.models import Q
 from django.db.models.expressions import RawSQL
 from django.db.models.expressions import RawSQL
 from django.urls import reverse
 from django.urls import reverse
 from django.utils.encoding import python_2_unicode_compatible
 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])
             child_prefixes = netaddr.IPSet([p.prefix for p in queryset])
             return int(float(child_prefixes.size) / self.prefix.size * 100)
             return int(float(child_prefixes.size) / self.prefix.size * 100)
         else:
         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
             prefix_size = self.prefix.size
             if self.family == 4 and self.prefix.prefixlen < 31 and not self.is_pool:
             if self.family == 4 and self.prefix.prefixlen < 31 and not self.is_pool:
                 prefix_size -= 2
                 prefix_size -= 2
@@ -615,6 +617,13 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel):
     def get_status_class(self):
     def get_status_class(self):
         return STATUS_CHOICE_CLASSES[self.status]
         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
 @python_2_unicode_compatible
 class Service(CreatedUpdatedModel):
 class Service(CreatedUpdatedModel):

+ 31 - 0
netbox/ipam/tables.py

@@ -3,6 +3,7 @@ from __future__ import unicode_literals
 import django_tables2 as tables
 import django_tables2 as tables
 from django_tables2.utils import Accessor
 from django_tables2.utils import Accessor
 
 
+from dcim.models import Interface
 from tenancy.tables import COL_TENANT
 from tenancy.tables import COL_TENANT
 from utilities.tables import BaseTable, ToggleColumn
 from utilities.tables import BaseTable, ToggleColumn
 from .models import Aggregate, IPAddress, Prefix, RIR, Role, VLAN, VLANGroup, VRF
 from .models import Aggregate, IPAddress, Prefix, RIR, Role, VLAN, VLANGroup, VRF
@@ -138,6 +139,18 @@ VLANGROUP_ACTIONS = """
 {% endif %}
 {% 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 = """
 TENANT_LINK = """
 {% if record.tenant %}
 {% if record.tenant %}
     <a href="{% url 'tenancy:tenant' slug=record.tenant.slug %}" title="{{ record.tenant.description }}">{{ record.tenant }}</a>
     <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):
     class Meta(VLANTable.Meta):
         fields = ('pk', 'vid', 'site', 'group', 'name', 'prefixes', 'tenant', 'status', 'role', 'description')
         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/edit/$', views.VLANBulkEditView.as_view(), name='vlan_bulk_edit'),
     url(r'^vlans/delete/$', views.VLANBulkDeleteView.as_view(), name='vlan_bulk_delete'),
     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+)/$', 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+)/edit/$', views.VLANEditView.as_view(), name='vlan_edit'),
     url(r'^vlans/(?P<pk>\d+)/delete/$', views.VLANDeleteView.as_view(), name='vlan_delete'),
     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):
 class VLANCreateView(PermissionRequiredMixin, ObjectEditView):
     permission_required = 'ipam.add_vlan'
     permission_required = 'ipam.add_vlan'
     model = VLAN
     model = VLAN

+ 28 - 2
netbox/netbox/settings.py

@@ -22,7 +22,7 @@ if sys.version_info[0] < 3:
         DeprecationWarning
         DeprecationWarning
     )
     )
 
 
-VERSION = '2.3.1'
+VERSION = '2.3.2'
 
 
 BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
 BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
 
 
@@ -133,7 +133,6 @@ INSTALLED_APPS = (
     'django_tables2',
     'django_tables2',
     'mptt',
     'mptt',
     'rest_framework',
     'rest_framework',
-    'rest_framework_swagger',
     'timezone_field',
     'timezone_field',
     'circuits',
     'circuits',
     'dcim',
     'dcim',
@@ -144,6 +143,7 @@ INSTALLED_APPS = (
     'users',
     'users',
     'utilities',
     'utilities',
     'virtualization',
     'virtualization',
+    'drf_yasg',
 )
 )
 
 
 # Middleware
 # Middleware
@@ -246,6 +246,32 @@ REST_FRAMEWORK = {
     'VIEW_NAME_FUNCTION': 'netbox.api.get_view_name',
     '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
 # Django debug toolbar
 INTERNAL_IPS = (
 INTERNAL_IPS = (
     '127.0.0.1',
     '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.conf.urls import include, url
 from django.contrib import admin
 from django.contrib import admin
 from django.views.static import serve
 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 netbox.views import APIRootView, HomeView, SearchView
 from users.views import LoginView, LogoutView
 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 = [
 _patterns = [
 
 
@@ -40,7 +52,9 @@ _patterns = [
     url(r'^api/secrets/', include('secrets.api.urls')),
     url(r'^api/secrets/', include('secrets.api.urls')),
     url(r'^api/tenancy/', include('tenancy.api.urls')),
     url(r'^api/tenancy/', include('tenancy.api.urls')),
     url(r'^api/virtualization/', include('virtualization.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
     # Serving static media in Django to pipe it through LoginRequiredMiddleware
     url(r'^media/(?P<path>.*)$', serve, {'document_root': settings.MEDIA_ROOT}),
     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):
 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:
     class Meta:
         model = Secret
         model = Secret
         fields = ['role', 'name', 'plaintext', 'plaintext2']
         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):
     def clean(self):
 
 
+        # Verify that the provided plaintext values match
         if self.cleaned_data['plaintext'] != self.cleaned_data['plaintext2']:
         if self.cleaned_data['plaintext'] != self.cleaned_data['plaintext2']:
             raise forms.ValidationError({
             raise forms.ValidationError({
                 'plaintext2': "The two given plaintext values do not match. Please check your input."
                 '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.mtu %}
             {% render_field form.mgmt_only %}
             {% render_field form.mgmt_only %}
             {% render_field form.description %}
             {% 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.mode %}
-            {% render_field form.site %}
-            {% render_field form.vlan_group %}
-            {% render_field form.untagged_vlan %}
-            {% render_field form.tagged_vlans %}
         </div>
         </div>
     </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 %}
 {% 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' %}
 {% extends '_base.html' %}
 
 
 {% block content %}
 {% 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="row">
 	<div class="col-md-6">
 	<div class="col-md-6">
         <div class="panel panel-default">
         <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>
         <div class="row">
         <div class="row">
             <div class="col-md-6 col-md-offset-3 text-right">
             <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>
         </div>
         </div>
     </form>
     </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 import forms
 from django.conf import settings
 from django.conf import settings
+from django.db.models import Count
 from django.urls import reverse_lazy
 from django.urls import reverse_lazy
 from mptt.forms import TreeNodeMultipleChoiceField
 from mptt.forms import TreeNodeMultipleChoiceField
 
 
@@ -38,6 +39,7 @@ COLOR_CHOICES = (
     ('111111', 'Black'),
     ('111111', 'Black'),
 )
 )
 NUMERIC_EXPANSION_PATTERN = '\[((?:\d+[?:,-])+\d+)\]'
 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})\]'
 IP4_EXPANSION_PATTERN = '\[((?:[0-9]{1,3}[?:,-])+[0-9]{1,3})\]'
 IP6_EXPANSION_PATTERN = '\[((?:[0-9a-f]{1,4}[?:,-])+[0-9a-f]{1,4})\]'
 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)
             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):
 def expand_ipaddress_pattern(string, family):
     """
     """
     Expand an IP address pattern into a list of strings. Examples:
     Expand an IP address pattern into a list of strings. Examples:
@@ -305,12 +346,15 @@ class ExpandableNameField(forms.CharField):
     def __init__(self, *args, **kwargs):
     def __init__(self, *args, **kwargs):
         super(ExpandableNameField, self).__init__(*args, **kwargs)
         super(ExpandableNameField, self).__init__(*args, **kwargs)
         if not self.help_text:
         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):
     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]
         return [value]
 
 
 
 
@@ -450,6 +494,38 @@ class FilterTreeNodeMultipleChoiceField(FilterChoiceFieldMixin, TreeNodeMultiple
     pass
     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):
 class LaxURLField(forms.URLField):
     """
     """
     Modifies Django's built-in URLField in two ways:
     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 django.db.models import Count
 from mptt.forms import TreeNodeChoiceField
 from mptt.forms import TreeNodeChoiceField
 
 
-from dcim.constants import IFACE_FF_VIRTUAL
+from dcim.constants import IFACE_FF_VIRTUAL, IFACE_MODE_ACCESS, IFACE_MODE_TAGGED_ALL
+from dcim.forms import INTERFACE_MODE_HELP_TEXT
 from dcim.formfields import MACAddressFormField
 from dcim.formfields import MACAddressFormField
 from dcim.models import Device, DeviceRole, Interface, Platform, Rack, Region, Site
 from dcim.models import Device, DeviceRole, Interface, Platform, Rack, Region, Site
 from extras.forms import CustomFieldBulkEditForm, CustomFieldForm, CustomFieldFilterForm
 from extras.forms import CustomFieldBulkEditForm, CustomFieldForm, CustomFieldFilterForm
@@ -13,9 +14,9 @@ from ipam.models import IPAddress
 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 (
-    add_blank_choice, APISelect, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect,
+    AnnotatedMultipleChoiceField, APISelect, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect,
     ChainedFieldsMixin, ChainedModelChoiceField, ChainedModelMultipleChoiceField, CommentField, ComponentForm,
     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 .constants import VM_STATUS_CHOICES
 from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
 from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
@@ -361,13 +362,6 @@ class VirtualMachineBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
         nullable_fields = ['role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments']
         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):
 class VirtualMachineFilterForm(BootstrapMixin, CustomFieldFilterForm):
     model = VirtualMachine
     model = VirtualMachine
     q = forms.CharField(required=False, label='Search')
     q = forms.CharField(required=False, label='Search')
@@ -395,7 +389,12 @@ class VirtualMachineFilterForm(BootstrapMixin, CustomFieldFilterForm):
         to_field_name='slug',
         to_field_name='slug',
         null_label='-- None --'
         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(
     tenant = FilterChoiceField(
         queryset=Tenant.objects.annotate(filter_count=Count('virtual_machines')),
         queryset=Tenant.objects.annotate(filter_count=Count('virtual_machines')),
         to_field_name='slug',
         to_field_name='slug',
@@ -416,11 +415,37 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm):
 
 
     class Meta:
     class Meta:
         model = Interface
         model = Interface
-        fields = ['virtual_machine', 'name', 'form_factor', 'enabled', 'mac_address', 'mtu', 'description']
+        fields = [
+            'virtual_machine', 'name', 'form_factor', 'enabled', 'mac_address', 'mtu', 'description', 'mode',
+            'untagged_vlan', 'tagged_vlans',
+        ]
         widgets = {
         widgets = {
             'virtual_machine': forms.HiddenInput(),
             'virtual_machine': forms.HiddenInput(),
             'form_factor': forms.HiddenInput(),
             'form_factor': forms.HiddenInput(),
         }
         }
+        labels = {
+            'mode': '802.1Q Mode',
+        }
+        help_texts = {
+            'mode': INTERFACE_MODE_HELP_TEXT,
+        }
+
+    def clean(self):
+
+        super(InterfaceForm, self).clean()
+
+        # Validate VLAN assignments
+        tagged_vlans = self.cleaned_data['tagged_vlans']
+
+        # Untagged interfaces cannot be assigned tagged VLANs
+        if self.cleaned_data['mode'] == IFACE_MODE_ACCESS and tagged_vlans:
+            raise forms.ValidationError({
+                'mode': "An access interface cannot have tagged VLANs assigned."
+            })
+
+        # Remove all tagged VLAN assignments from "tagged all" interfaces
+        elif self.cleaned_data['mode'] == IFACE_MODE_TAGGED_ALL:
+            self.cleaned_data['tagged_vlans'] = []
 
 
 
 
 class InterfaceCreateForm(ComponentForm):
 class InterfaceCreateForm(ComponentForm):

+ 1 - 2
netbox/virtualization/models.py

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

+ 1 - 0
netbox/virtualization/views.py

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

+ 1 - 0
old_requirements.txt

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

+ 1 - 1
requirements.txt

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