소스 검색

Merge pull request #324 from digitalocean/develop

Release v1.3.0
Jeremy Stretch 9 년 전
부모
커밋
5ba5e8def9
36개의 변경된 파일751개의 추가작업 그리고 74개의 파일을 삭제
  1. 9 1
      docs/configuration/optional-settings.md
  2. 1 1
      netbox/dcim/api/serializers.py
  3. 62 18
      netbox/dcim/forms.py
  4. 21 0
      netbox/dcim/migrations/0010_devicebay_installed_device_set_null.py
  5. 10 5
      netbox/dcim/models.py
  6. 1 0
      netbox/dcim/urls.py
  7. 17 0
      netbox/dcim/views.py
  8. 9 1
      netbox/ipam/admin.py
  9. 22 3
      netbox/ipam/api/serializers.py
  10. 4 0
      netbox/ipam/api/urls.py
  11. 56 7
      netbox/ipam/api/views.py
  12. 30 1
      netbox/ipam/filters.py
  13. 92 8
      netbox/ipam/forms.py
  14. 20 0
      netbox/ipam/migrations/0002_vrf_add_enforce_unique.py
  15. 38 0
      netbox/ipam/migrations/0003_ipam_add_vlangroups.py
  16. 27 0
      netbox/ipam/migrations/0004_ipam_vlangroup_uniqueness.py
  17. 68 3
      netbox/ipam/models.py
  18. 26 2
      netbox/ipam/tables.py
  19. 6 0
      netbox/ipam/urls.py
  20. 28 1
      netbox/ipam/views.py
  21. 4 0
      netbox/netbox/configuration.example.py
  22. 2 1
      netbox/netbox/settings.py
  23. 3 1
      netbox/netbox/urls.py
  24. 10 5
      netbox/netbox/views.py
  25. 16 0
      netbox/project-static/css/base.css
  26. 8 2
      netbox/templates/500.html
  27. 13 10
      netbox/templates/_base.html
  28. 1 1
      netbox/templates/dcim/device_import.html
  29. 75 0
      netbox/templates/dcim/device_import_child.html
  30. 5 0
      netbox/templates/dcim/inc/_device_import_header.html
  31. 11 1
      netbox/templates/ipam/prefix_import.html
  32. 10 0
      netbox/templates/ipam/vlan.html
  33. 6 1
      netbox/templates/ipam/vlan_import.html
  34. 24 0
      netbox/templates/ipam/vlangroup_list.html
  35. 10 0
      netbox/templates/ipam/vrf.html
  36. 6 1
      netbox/templates/ipam/vrf_import.html

+ 9 - 1
docs/configuration/optional-settings.md

@@ -47,9 +47,17 @@ In order to send email, NetBox needs an email server configured. The following i
 
 
 ---
 ---
 
 
+# ENFORCE_GLOBAL_UNIQUE
+
+Default: False
+
+Enforcement of unique IP space can be toggled on a per-VRF basis. To enforce unique IP space within the global table (all prefixes and IP addresses not assigned to a VRF), set `ENFORCE_GLOBAL_UNIQUE` to True.
+
+---
+
 ## LOGIN_REQUIRED
 ## LOGIN_REQUIRED
 
 
-Default: False,
+Default: False
 
 
 Setting this to True will permit only authenticated users to access any part of NetBox. By default, anonymous users are permitted to access most data in NetBox (excluding secrets) but not make any changes.
 Setting this to True will permit only authenticated users to access any part of NetBox. By default, anonymous users are permitted to access most data in NetBox (excluding secrets) but not make any changes.
 
 

+ 1 - 1
netbox/dcim/api/serializers.py

@@ -38,7 +38,7 @@ class RackGroupSerializer(serializers.ModelSerializer):
         fields = ['id', 'name', 'slug', 'site']
         fields = ['id', 'name', 'slug', 'site']
 
 
 
 
-class RackGroupNestedSerializer(SiteSerializer):
+class RackGroupNestedSerializer(RackGroupSerializer):
 
 
     class Meta(SiteSerializer.Meta):
     class Meta(SiteSerializer.Meta):
         fields = ['id', 'name', 'slug']
         fields = ['id', 'name', 'slug']

+ 62 - 18
netbox/dcim/forms.py

@@ -426,7 +426,7 @@ class DeviceForm(forms.ModelForm, BootstrapMixin):
             self.fields['device_type'].choices = []
             self.fields['device_type'].choices = []
 
 
 
 
-class DeviceFromCSVForm(forms.ModelForm):
+class BaseDeviceFromCSVForm(forms.ModelForm):
     device_role = forms.ModelChoiceField(queryset=DeviceRole.objects.all(), to_field_name='name',
     device_role = forms.ModelChoiceField(queryset=DeviceRole.objects.all(), to_field_name='name',
                                          error_messages={'invalid_choice': 'Invalid device role.'})
                                          error_messages={'invalid_choice': 'Invalid device role.'})
     manufacturer = forms.ModelChoiceField(queryset=Manufacturer.objects.all(), to_field_name='name',
     manufacturer = forms.ModelChoiceField(queryset=Manufacturer.objects.all(), to_field_name='name',
@@ -434,23 +434,15 @@ class DeviceFromCSVForm(forms.ModelForm):
     model_name = forms.CharField()
     model_name = forms.CharField()
     platform = forms.ModelChoiceField(queryset=Platform.objects.all(), required=False, to_field_name='name',
     platform = forms.ModelChoiceField(queryset=Platform.objects.all(), required=False, to_field_name='name',
                                       error_messages={'invalid_choice': 'Invalid platform.'})
                                       error_messages={'invalid_choice': 'Invalid platform.'})
-    site = forms.ModelChoiceField(queryset=Site.objects.all(), to_field_name='name', error_messages={
-        'invalid_choice': 'Invalid site name.',
-    })
-    rack_name = forms.CharField()
-    face = forms.CharField(required=False)
 
 
     class Meta:
     class Meta:
+        fields = []
         model = Device
         model = Device
-        fields = ['name', 'device_role', 'manufacturer', 'model_name', 'platform', 'serial', 'site', 'rack_name',
-                  'position', 'face']
 
 
     def clean(self):
     def clean(self):
 
 
         manufacturer = self.cleaned_data.get('manufacturer')
         manufacturer = self.cleaned_data.get('manufacturer')
         model_name = self.cleaned_data.get('model_name')
         model_name = self.cleaned_data.get('model_name')
-        site = self.cleaned_data.get('site')
-        rack_name = self.cleaned_data.get('rack_name')
 
 
         # Validate device type
         # Validate device type
         if manufacturer and model_name:
         if manufacturer and model_name:
@@ -459,6 +451,25 @@ class DeviceFromCSVForm(forms.ModelForm):
             except DeviceType.DoesNotExist:
             except DeviceType.DoesNotExist:
                 self.add_error('model_name', "Invalid device type ({} {})".format(manufacturer, model_name))
                 self.add_error('model_name', "Invalid device type ({} {})".format(manufacturer, model_name))
 
 
+
+class DeviceFromCSVForm(BaseDeviceFromCSVForm):
+    site = forms.ModelChoiceField(queryset=Site.objects.all(), to_field_name='name', error_messages={
+        'invalid_choice': 'Invalid site name.',
+    })
+    rack_name = forms.CharField()
+    face = forms.CharField(required=False)
+
+    class Meta(BaseDeviceFromCSVForm.Meta):
+        fields = ['name', 'device_role', 'manufacturer', 'model_name', 'platform', 'serial', 'site', 'rack_name',
+                  'position', 'face']
+
+    def clean(self):
+
+        super(DeviceFromCSVForm, self).clean()
+
+        site = self.cleaned_data.get('site')
+        rack_name = self.cleaned_data.get('rack_name')
+
         # Validate rack
         # Validate rack
         if site and rack_name:
         if site and rack_name:
             try:
             try:
@@ -468,21 +479,54 @@ class DeviceFromCSVForm(forms.ModelForm):
 
 
     def clean_face(self):
     def clean_face(self):
         face = self.cleaned_data['face']
         face = self.cleaned_data['face']
-        if face:
+        if not face:
+            return None
+        try:
+            return {
+                'front': 0,
+                'rear': 1,
+            }[face.lower()]
+        except KeyError:
+            raise forms.ValidationError('Invalid rack face ({}); must be "front" or "rear".'.format(face))
+
+
+class ChildDeviceFromCSVForm(BaseDeviceFromCSVForm):
+    parent = FlexibleModelChoiceField(queryset=Device.objects.all(), to_field_name='name', required=False,
+                                      error_messages={'invalid_choice': 'Parent device not found.'})
+    device_bay_name = forms.CharField(required=False)
+
+    class Meta(BaseDeviceFromCSVForm.Meta):
+        fields = ['name', 'device_role', 'manufacturer', 'model_name', 'platform', 'serial', 'parent',
+                  'device_bay_name']
+
+    def clean(self):
+
+        super(ChildDeviceFromCSVForm, self).clean()
+
+        parent = self.cleaned_data.get('parent')
+        device_bay_name = self.cleaned_data.get('device_bay_name')
+
+        # Validate device bay
+        if parent and device_bay_name:
             try:
             try:
-                return {
-                    'front': 0,
-                    'rear': 1,
-                }[face.lower()]
-            except KeyError:
-                raise forms.ValidationError('Invalid rack face ({}); must be "front" or "rear".'.format(face))
-        return face
+                device_bay = DeviceBay.objects.get(device=parent, name=device_bay_name)
+                if device_bay.installed_device:
+                    self.add_error('device_bay_name',
+                                   "Device bay ({} {}) is already occupied".format(parent, device_bay_name))
+                else:
+                    self.instance.parent_bay = device_bay
+            except DeviceBay.DoesNotExist:
+                self.add_error('device_bay_name', "Parent device/bay ({} {}) not found".format(parent, device_bay_name))
 
 
 
 
 class DeviceImportForm(BulkImportForm, BootstrapMixin):
 class DeviceImportForm(BulkImportForm, BootstrapMixin):
     csv = CSVDataField(csv_form=DeviceFromCSVForm)
     csv = CSVDataField(csv_form=DeviceFromCSVForm)
 
 
 
 
+class ChildDeviceImportForm(BulkImportForm, BootstrapMixin):
+    csv = CSVDataField(csv_form=ChildDeviceFromCSVForm)
+
+
 class DeviceBulkEditForm(forms.Form, BootstrapMixin):
 class DeviceBulkEditForm(forms.Form, BootstrapMixin):
     pk = forms.ModelMultipleChoiceField(queryset=Device.objects.all(), widget=forms.MultipleHiddenInput)
     pk = forms.ModelMultipleChoiceField(queryset=Device.objects.all(), widget=forms.MultipleHiddenInput)
     device_type = forms.ModelChoiceField(queryset=DeviceType.objects.all(), required=False, label='Type')
     device_type = forms.ModelChoiceField(queryset=DeviceType.objects.all(), required=False, label='Type')

+ 21 - 0
netbox/dcim/migrations/0010_devicebay_installed_device_set_null.py

@@ -0,0 +1,21 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.9.7 on 2016-07-14 21:38
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0009_site_32bit_asn_support'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='devicebay',
+            name='installed_device',
+            field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='parent_bay', to='dcim.Device'),
+        ),
+    ]

+ 10 - 5
netbox/dcim/models.py

@@ -624,6 +624,10 @@ class Device(CreatedUpdatedModel):
 
 
     def clean(self):
     def clean(self):
 
 
+        # Validate device type assignment
+        if not hasattr(self, 'device_type'):
+            raise ValidationError("Must specify device type.")
+
         # Child devices cannot be assigned to a rack face/unit
         # Child devices cannot be assigned to a rack face/unit
         if self.device_type.is_child_device and (self.face is not None or self.position):
         if self.device_type.is_child_device and (self.face is not None or self.position):
             raise ValidationError("Child device types cannot be assigned a rack face or position.")
             raise ValidationError("Child device types cannot be assigned a rack face or position.")
@@ -633,10 +637,7 @@ class Device(CreatedUpdatedModel):
             raise ValidationError("Must specify rack face with rack position.")
             raise ValidationError("Must specify rack face with rack position.")
 
 
         # Validate rack space
         # Validate rack space
-        try:
-            rack_face = self.face if not self.device_type.is_full_depth else None
-        except DeviceType.DoesNotExist:
-            raise ValidationError("Must specify device type.")
+        rack_face = self.face if not self.device_type.is_full_depth else None
         exclude_list = [self.pk] if self.pk else []
         exclude_list = [self.pk] if self.pk else []
         try:
         try:
             available_units = self.rack.get_available_units(u_height=self.device_type.u_height, rack_face=rack_face,
             available_units = self.rack.get_available_units(u_height=self.device_type.u_height, rack_face=rack_face,
@@ -680,6 +681,9 @@ class Device(CreatedUpdatedModel):
                  self.device_type.device_bay_templates.all()]
                  self.device_type.device_bay_templates.all()]
             )
             )
 
 
+        # Update Rack assignment for any child Devices
+        Device.objects.filter(parent_bay__device=self).update(rack=self.rack)
+
     def to_csv(self):
     def to_csv(self):
         return ','.join([
         return ','.join([
             self.name or '',
             self.name or '',
@@ -953,7 +957,8 @@ class DeviceBay(models.Model):
     """
     """
     device = models.ForeignKey('Device', related_name='device_bays', on_delete=models.CASCADE)
     device = models.ForeignKey('Device', related_name='device_bays', on_delete=models.CASCADE)
     name = models.CharField(max_length=50, verbose_name='Name')
     name = models.CharField(max_length=50, verbose_name='Name')
-    installed_device = models.OneToOneField('Device', related_name='parent_bay', blank=True, null=True)
+    installed_device = models.OneToOneField('Device', related_name='parent_bay', on_delete=models.SET_NULL, blank=True,
+                                            null=True)
 
 
     class Meta:
     class Meta:
         ordering = ['device', 'name']
         ordering = ['device', 'name']

+ 1 - 0
netbox/dcim/urls.py

@@ -92,6 +92,7 @@ urlpatterns = [
     url(r'^devices/$', views.DeviceListView.as_view(), name='device_list'),
     url(r'^devices/$', views.DeviceListView.as_view(), name='device_list'),
     url(r'^devices/add/$', views.DeviceEditView.as_view(), name='device_add'),
     url(r'^devices/add/$', views.DeviceEditView.as_view(), name='device_add'),
     url(r'^devices/import/$', views.DeviceBulkImportView.as_view(), name='device_import'),
     url(r'^devices/import/$', views.DeviceBulkImportView.as_view(), name='device_import'),
+    url(r'^devices/import/child-devices/$', views.ChildDeviceBulkImportView.as_view(), name='device_import_child'),
     url(r'^devices/edit/$', views.DeviceBulkEditView.as_view(), name='device_bulk_edit'),
     url(r'^devices/edit/$', views.DeviceBulkEditView.as_view(), name='device_bulk_edit'),
     url(r'^devices/delete/$', views.DeviceBulkDeleteView.as_view(), name='device_bulk_delete'),
     url(r'^devices/delete/$', views.DeviceBulkDeleteView.as_view(), name='device_bulk_delete'),
     url(r'^devices/(?P<pk>\d+)/$', views.device, name='device'),
     url(r'^devices/(?P<pk>\d+)/$', views.device, name='device'),

+ 17 - 0
netbox/dcim/views.py

@@ -609,6 +609,23 @@ class DeviceBulkImportView(PermissionRequiredMixin, BulkImportView):
     obj_list_url = 'dcim:device_list'
     obj_list_url = 'dcim:device_list'
 
 
 
 
+class ChildDeviceBulkImportView(PermissionRequiredMixin, BulkImportView):
+    permission_required = 'dcim.add_device'
+    form = forms.ChildDeviceImportForm
+    table = tables.DeviceImportTable
+    template_name = 'dcim/device_import_child.html'
+    obj_list_url = 'dcim:device_list'
+
+    def save_obj(self, obj):
+        # Inherent rack from parent device
+        obj.rack = obj.parent_bay.device.rack
+        obj.save()
+        # Save the reverse relation
+        device_bay = obj.parent_bay
+        device_bay.installed_device = obj
+        device_bay.save()
+
+
 class DeviceBulkEditView(PermissionRequiredMixin, BulkEditView):
 class DeviceBulkEditView(PermissionRequiredMixin, BulkEditView):
     permission_required = 'dcim.change_device'
     permission_required = 'dcim.change_device'
     cls = Device
     cls = Device

+ 9 - 1
netbox/ipam/admin.py

@@ -1,7 +1,7 @@
 from django.contrib import admin
 from django.contrib import admin
 
 
 from .models import (
 from .models import (
-    Aggregate, IPAddress, Prefix, RIR, Role, VLAN, VRF,
+    Aggregate, IPAddress, Prefix, RIR, Role, VLAN, VLANGroup, VRF,
 )
 )
 
 
 
 
@@ -57,6 +57,14 @@ class IPAddressAdmin(admin.ModelAdmin):
         return qs.select_related('vrf', 'nat_inside')
         return qs.select_related('vrf', 'nat_inside')
 
 
 
 
+@admin.register(VLANGroup)
+class VLANGroupAdmin(admin.ModelAdmin):
+    list_display = ['name', 'site', 'slug']
+    prepopulated_fields = {
+        'slug': ['name'],
+    }
+
+
 @admin.register(VLAN)
 @admin.register(VLAN)
 class VLANAdmin(admin.ModelAdmin):
 class VLANAdmin(admin.ModelAdmin):
     list_display = ['site', 'vid', 'name', 'status', 'role']
     list_display = ['site', 'vid', 'name', 'status', 'role']

+ 22 - 3
netbox/ipam/api/serializers.py

@@ -1,7 +1,7 @@
 from rest_framework import serializers
 from rest_framework import serializers
 
 
 from dcim.api.serializers import SiteNestedSerializer, InterfaceNestedSerializer
 from dcim.api.serializers import SiteNestedSerializer, InterfaceNestedSerializer
-from ipam.models import VRF, Role, RIR, Aggregate, Prefix, IPAddress, VLAN
+from ipam.models import VRF, Role, RIR, Aggregate, Prefix, IPAddress, VLAN, VLANGroup
 
 
 
 
 #
 #
@@ -12,7 +12,7 @@ class VRFSerializer(serializers.ModelSerializer):
 
 
     class Meta:
     class Meta:
         model = VRF
         model = VRF
-        fields = ['id', 'name', 'rd', 'description']
+        fields = ['id', 'name', 'rd', 'enforce_unique', 'description']
 
 
 
 
 class VRFNestedSerializer(VRFSerializer):
 class VRFNestedSerializer(VRFSerializer):
@@ -73,17 +73,36 @@ class AggregateNestedSerializer(AggregateSerializer):
         fields = ['id', 'family', 'prefix']
         fields = ['id', 'family', 'prefix']
 
 
 
 
+#
+# VLAN groups
+#
+
+class VLANGroupSerializer(serializers.ModelSerializer):
+    site = SiteNestedSerializer()
+
+    class Meta:
+        model = VLANGroup
+        fields = ['id', 'name', 'slug', 'site']
+
+
+class VLANGroupNestedSerializer(VLANGroupSerializer):
+
+    class Meta(VLANGroupSerializer.Meta):
+        fields = ['id', 'name', 'slug']
+
+
 #
 #
 # VLANs
 # VLANs
 #
 #
 
 
 class VLANSerializer(serializers.ModelSerializer):
 class VLANSerializer(serializers.ModelSerializer):
     site = SiteNestedSerializer()
     site = SiteNestedSerializer()
+    group = VLANGroupNestedSerializer()
     role = RoleNestedSerializer()
     role = RoleNestedSerializer()
 
 
     class Meta:
     class Meta:
         model = VLAN
         model = VLAN
-        fields = ['id', 'site', 'vid', 'name', 'status', 'role', 'display_name']
+        fields = ['id', 'site', 'group', 'vid', 'name', 'status', 'role', 'display_name']
 
 
 
 
 class VLANNestedSerializer(VLANSerializer):
 class VLANNestedSerializer(VLANSerializer):

+ 4 - 0
netbox/ipam/api/urls.py

@@ -29,6 +29,10 @@ urlpatterns = [
     url(r'^ip-addresses/$', IPAddressListView.as_view(), name='ipaddress_list'),
     url(r'^ip-addresses/$', IPAddressListView.as_view(), name='ipaddress_list'),
     url(r'^ip-addresses/(?P<pk>\d+)/$', IPAddressDetailView.as_view(), name='ipaddress_detail'),
     url(r'^ip-addresses/(?P<pk>\d+)/$', IPAddressDetailView.as_view(), name='ipaddress_detail'),
 
 
+    # VLAN groups
+    url(r'^vlan-groups/$', VLANGroupListView.as_view(), name='vlangroup_list'),
+    url(r'^vlan-groups/(?P<pk>\d+)/$', VLANGroupDetailView.as_view(), name='vlangroup_detail'),
+
     # VLANs
     # VLANs
     url(r'^vlans/$', VLANListView.as_view(), name='vlan_list'),
     url(r'^vlans/$', VLANListView.as_view(), name='vlan_list'),
     url(r'^vlans/(?P<pk>\d+)/$', VLANDetailView.as_view(), name='vlan_detail'),
     url(r'^vlans/(?P<pk>\d+)/$', VLANDetailView.as_view(), name='vlan_detail'),

+ 56 - 7
netbox/ipam/api/views.py

@@ -1,18 +1,22 @@
 from rest_framework import generics
 from rest_framework import generics
 
 
-from ipam.models import VRF, Role, RIR, Aggregate, Prefix, IPAddress, VLAN
-from ipam.filters import AggregateFilter, PrefixFilter, IPAddressFilter, VLANFilter, VRFFilter
+from ipam.models import VRF, Role, RIR, Aggregate, Prefix, IPAddress, VLAN, VLANGroup
+from ipam import filters
 
 
 from . import serializers
 from . import serializers
 
 
 
 
+#
+# VRFs
+#
+
 class VRFListView(generics.ListAPIView):
 class VRFListView(generics.ListAPIView):
     """
     """
     List all VRFs
     List all VRFs
     """
     """
     queryset = VRF.objects.all()
     queryset = VRF.objects.all()
     serializer_class = serializers.VRFSerializer
     serializer_class = serializers.VRFSerializer
-    filter_class = VRFFilter
+    filter_class = filters.VRFFilter
 
 
 
 
 class VRFDetailView(generics.RetrieveAPIView):
 class VRFDetailView(generics.RetrieveAPIView):
@@ -23,6 +27,10 @@ class VRFDetailView(generics.RetrieveAPIView):
     serializer_class = serializers.VRFSerializer
     serializer_class = serializers.VRFSerializer
 
 
 
 
+#
+# Roles
+#
+
 class RoleListView(generics.ListAPIView):
 class RoleListView(generics.ListAPIView):
     """
     """
     List all roles
     List all roles
@@ -39,6 +47,10 @@ class RoleDetailView(generics.RetrieveAPIView):
     serializer_class = serializers.RoleSerializer
     serializer_class = serializers.RoleSerializer
 
 
 
 
+#
+# RIRs
+#
+
 class RIRListView(generics.ListAPIView):
 class RIRListView(generics.ListAPIView):
     """
     """
     List all RIRs
     List all RIRs
@@ -55,13 +67,17 @@ class RIRDetailView(generics.RetrieveAPIView):
     serializer_class = serializers.RIRSerializer
     serializer_class = serializers.RIRSerializer
 
 
 
 
+#
+# Aggregates
+#
+
 class AggregateListView(generics.ListAPIView):
 class AggregateListView(generics.ListAPIView):
     """
     """
     List aggregates (filterable)
     List aggregates (filterable)
     """
     """
     queryset = Aggregate.objects.select_related('rir')
     queryset = Aggregate.objects.select_related('rir')
     serializer_class = serializers.AggregateSerializer
     serializer_class = serializers.AggregateSerializer
-    filter_class = AggregateFilter
+    filter_class = filters.AggregateFilter
 
 
 
 
 class AggregateDetailView(generics.RetrieveAPIView):
 class AggregateDetailView(generics.RetrieveAPIView):
@@ -72,13 +88,17 @@ class AggregateDetailView(generics.RetrieveAPIView):
     serializer_class = serializers.AggregateSerializer
     serializer_class = serializers.AggregateSerializer
 
 
 
 
+#
+# Prefixes
+#
+
 class PrefixListView(generics.ListAPIView):
 class PrefixListView(generics.ListAPIView):
     """
     """
     List prefixes (filterable)
     List prefixes (filterable)
     """
     """
     queryset = Prefix.objects.select_related('site', 'vrf', 'vlan', 'role')
     queryset = Prefix.objects.select_related('site', 'vrf', 'vlan', 'role')
     serializer_class = serializers.PrefixSerializer
     serializer_class = serializers.PrefixSerializer
-    filter_class = PrefixFilter
+    filter_class = filters.PrefixFilter
 
 
 
 
 class PrefixDetailView(generics.RetrieveAPIView):
 class PrefixDetailView(generics.RetrieveAPIView):
@@ -89,6 +109,10 @@ class PrefixDetailView(generics.RetrieveAPIView):
     serializer_class = serializers.PrefixSerializer
     serializer_class = serializers.PrefixSerializer
 
 
 
 
+#
+# IP addresses
+#
+
 class IPAddressListView(generics.ListAPIView):
 class IPAddressListView(generics.ListAPIView):
     """
     """
     List IP addresses (filterable)
     List IP addresses (filterable)
@@ -96,7 +120,7 @@ class IPAddressListView(generics.ListAPIView):
     queryset = IPAddress.objects.select_related('vrf', 'interface__device', 'nat_inside')\
     queryset = IPAddress.objects.select_related('vrf', 'interface__device', 'nat_inside')\
         .prefetch_related('nat_outside')
         .prefetch_related('nat_outside')
     serializer_class = serializers.IPAddressSerializer
     serializer_class = serializers.IPAddressSerializer
-    filter_class = IPAddressFilter
+    filter_class = filters.IPAddressFilter
 
 
 
 
 class IPAddressDetailView(generics.RetrieveAPIView):
 class IPAddressDetailView(generics.RetrieveAPIView):
@@ -108,13 +132,38 @@ class IPAddressDetailView(generics.RetrieveAPIView):
     serializer_class = serializers.IPAddressSerializer
     serializer_class = serializers.IPAddressSerializer
 
 
 
 
+#
+# VLAN groups
+#
+
+class VLANGroupListView(generics.ListAPIView):
+    """
+    List all VLAN groups
+    """
+    queryset = VLANGroup.objects.all()
+    serializer_class = serializers.VLANGroupSerializer
+    filter_class = filters.VLANGroupFilter
+
+
+class VLANGroupDetailView(generics.RetrieveAPIView):
+    """
+    Retrieve a single VLAN group
+    """
+    queryset = VLANGroup.objects.all()
+    serializer_class = serializers.VLANGroupSerializer
+
+
+#
+# VLANs
+#
+
 class VLANListView(generics.ListAPIView):
 class VLANListView(generics.ListAPIView):
     """
     """
     List VLANs (filterable)
     List VLANs (filterable)
     """
     """
     queryset = VLAN.objects.select_related('site', 'role')
     queryset = VLAN.objects.select_related('site', 'role')
     serializer_class = serializers.VLANSerializer
     serializer_class = serializers.VLANSerializer
-    filter_class = VLANFilter
+    filter_class = filters.VLANFilter
 
 
 
 
 class VLANDetailView(generics.RetrieveAPIView):
 class VLANDetailView(generics.RetrieveAPIView):

+ 30 - 1
netbox/ipam/filters.py

@@ -4,7 +4,7 @@ from netaddr.core import AddrFormatError
 
 
 from dcim.models import Site, Device, Interface
 from dcim.models import Site, Device, Interface
 
 
-from .models import RIR, Aggregate, VRF, Prefix, IPAddress, VLAN, Role
+from .models import RIR, Aggregate, VRF, Prefix, IPAddress, VLAN, VLANGroup, Role
 
 
 
 
 class VRFFilter(django_filters.FilterSet):
 class VRFFilter(django_filters.FilterSet):
@@ -176,6 +176,24 @@ class IPAddressFilter(django_filters.FilterSet):
         return queryset.filter(vrf__pk=value)
         return queryset.filter(vrf__pk=value)
 
 
 
 
+class VLANGroupFilter(django_filters.FilterSet):
+    site_id = django_filters.ModelMultipleChoiceFilter(
+        name='site',
+        queryset=Site.objects.all(),
+        label='Site (ID)',
+    )
+    site = django_filters.ModelMultipleChoiceFilter(
+        name='site',
+        queryset=Site.objects.all(),
+        to_field_name='slug',
+        label='Site (slug)',
+    )
+
+    class Meta:
+        model = VLANGroup
+        fields = ['site_id', 'site']
+
+
 class VLANFilter(django_filters.FilterSet):
 class VLANFilter(django_filters.FilterSet):
     site_id = django_filters.ModelMultipleChoiceFilter(
     site_id = django_filters.ModelMultipleChoiceFilter(
         name='site',
         name='site',
@@ -188,6 +206,17 @@ class VLANFilter(django_filters.FilterSet):
         to_field_name='slug',
         to_field_name='slug',
         label='Site (slug)',
         label='Site (slug)',
     )
     )
+    group_id = django_filters.ModelMultipleChoiceFilter(
+        name='group',
+        queryset=VLANGroup.objects.all(),
+        label='Group (ID)',
+    )
+    group = django_filters.ModelMultipleChoiceFilter(
+        name='group',
+        queryset=VLANGroup.objects.all(),
+        to_field_name='slug',
+        label='Group',
+    )
     name = django_filters.CharFilter(
     name = django_filters.CharFilter(
         name='name',
         name='name',
         lookup_type='icontains',
         lookup_type='icontains',

+ 92 - 8
netbox/ipam/forms.py

@@ -9,7 +9,7 @@ from utilities.forms import (
 )
 )
 
 
 from .models import (
 from .models import (
-    Aggregate, IPAddress, Prefix, PREFIX_STATUS_CHOICES, RIR, Role, VLAN, VLAN_STATUS_CHOICES, VRF,
+    Aggregate, IPAddress, Prefix, PREFIX_STATUS_CHOICES, RIR, Role, VLAN, VLANGroup, VLAN_STATUS_CHOICES, VRF,
 )
 )
 
 
 
 
@@ -25,7 +25,7 @@ class VRFForm(forms.ModelForm, BootstrapMixin):
 
 
     class Meta:
     class Meta:
         model = VRF
         model = VRF
-        fields = ['name', 'rd', 'description']
+        fields = ['name', 'rd', 'enforce_unique', 'description']
         labels = {
         labels = {
             'rd': "RD",
             'rd': "RD",
         }
         }
@@ -38,7 +38,7 @@ class VRFFromCSVForm(forms.ModelForm):
 
 
     class Meta:
     class Meta:
         model = VRF
         model = VRF
-        fields = ['name', 'rd', 'description']
+        fields = ['name', 'rd', 'enforce_unique', 'description']
 
 
 
 
 class VRFImportForm(BulkImportForm, BootstrapMixin):
 class VRFImportForm(BulkImportForm, BootstrapMixin):
@@ -192,13 +192,43 @@ class PrefixFromCSVForm(forms.ModelForm):
                                  error_messages={'invalid_choice': 'VRF not found.'})
                                  error_messages={'invalid_choice': 'VRF not found.'})
     site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False, to_field_name='name',
     site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False, to_field_name='name',
                                   error_messages={'invalid_choice': 'Site not found.'})
                                   error_messages={'invalid_choice': 'Site not found.'})
+    vlan_group_name = forms.CharField(required=False)
+    vlan_vid = forms.IntegerField(required=False)
     status_name = forms.ChoiceField(choices=[(s[1], s[0]) for s in PREFIX_STATUS_CHOICES])
     status_name = forms.ChoiceField(choices=[(s[1], s[0]) for s in PREFIX_STATUS_CHOICES])
     role = forms.ModelChoiceField(queryset=Role.objects.all(), required=False, to_field_name='name',
     role = forms.ModelChoiceField(queryset=Role.objects.all(), required=False, to_field_name='name',
                                   error_messages={'invalid_choice': 'Invalid role.'})
                                   error_messages={'invalid_choice': 'Invalid role.'})
 
 
     class Meta:
     class Meta:
         model = Prefix
         model = Prefix
-        fields = ['prefix', 'vrf', 'site', 'status_name', 'role', 'description']
+        fields = ['prefix', 'vrf', 'site', 'vlan_group_name', 'vlan_vid', 'status_name', 'role', 'description']
+
+    def clean(self):
+
+        super(PrefixFromCSVForm, self).clean()
+
+        site = self.cleaned_data.get('site')
+        vlan_group_name = self.cleaned_data.get('vlan_group_name')
+        vlan_vid = self.cleaned_data.get('vlan_vid')
+
+        # Validate VLAN
+        vlan_group = None
+        if vlan_group_name:
+            try:
+                vlan_group = VLANGroup.objects.get(site=site, name=vlan_group_name)
+            except VLANGroup.DoesNotExist:
+                self.add_error('vlan_group_name', "Invalid VLAN group ({} - {}).".format(site, vlan_group_name))
+        if vlan_vid and vlan_group:
+            try:
+                self.instance.vlan = VLAN.objects.get(group=vlan_group, vid=vlan_vid)
+            except VLAN.DoesNotExist:
+                self.add_error('vlan_vid', "Invalid VLAN ID ({} - {}).".format(vlan_group, vlan_vid))
+        elif vlan_vid and site:
+            try:
+                self.instance.vlan = VLAN.objects.get(site=site, vid=vlan_vid)
+            except VLAN.MultipleObjectsReturned:
+                self.add_error('vlan_vid', "Multiple VLANs found ({} - VID {})".format(site, vlan_vid))
+        elif vlan_vid:
+            self.add_error('vlan_vid', "Must specify site and/or VLAN group when assigning a VLAN.")
 
 
     def save(self, *args, **kwargs):
     def save(self, *args, **kwargs):
         m = super(PrefixFromCSVForm, self).save(commit=False)
         m = super(PrefixFromCSVForm, self).save(commit=False)
@@ -368,9 +398,9 @@ class IPAddressFromCSVForm(forms.ModelForm):
                                                             name=self.cleaned_data['interface_name'])
                                                             name=self.cleaned_data['interface_name'])
         # Set as primary for device
         # Set as primary for device
         if self.cleaned_data['is_primary']:
         if self.cleaned_data['is_primary']:
-            if self.instance.family == 4:
+            if self.instance.address.version == 4:
                 self.instance.primary_ip4_for = self.cleaned_data['device']
                 self.instance.primary_ip4_for = self.cleaned_data['device']
-            elif self.instance.family == 6:
+            elif self.instance.address.version == 6:
                 self.instance.primary_ip6_for = self.cleaned_data['device']
                 self.instance.primary_ip6_for = self.cleaned_data['device']
 
 
         return super(IPAddressFromCSVForm, self).save(commit=commit)
         return super(IPAddressFromCSVForm, self).save(commit=commit)
@@ -407,34 +437,81 @@ class IPAddressFilterForm(forms.Form, BootstrapMixin):
     vrf = forms.ChoiceField(required=False, choices=ipaddress_vrf_choices, label='VRF')
     vrf = forms.ChoiceField(required=False, choices=ipaddress_vrf_choices, label='VRF')
 
 
 
 
+#
+# VLAN groups
+#
+
+class VLANGroupForm(forms.ModelForm, BootstrapMixin):
+    slug = SlugField()
+
+    class Meta:
+        model = VLANGroup
+        fields = ['site', 'name', 'slug']
+
+
+class VLANGroupBulkDeleteForm(ConfirmationForm):
+    pk = forms.ModelMultipleChoiceField(queryset=VLANGroup.objects.all(), widget=forms.MultipleHiddenInput)
+
+
+def vlangroup_site_choices():
+    site_choices = Site.objects.annotate(vlangroup_count=Count('vlan_groups'))
+    return [(s.slug, '{} ({})'.format(s.name, s.vlangroup_count)) for s in site_choices]
+
+
+class VLANGroupFilterForm(forms.Form, BootstrapMixin):
+    site = forms.MultipleChoiceField(required=False, choices=vlangroup_site_choices,
+                                     widget=forms.SelectMultiple(attrs={'size': 8}))
+
+
 #
 #
 # VLANs
 # VLANs
 #
 #
 
 
 class VLANForm(forms.ModelForm, BootstrapMixin):
 class VLANForm(forms.ModelForm, BootstrapMixin):
+    group = forms.ModelChoiceField(queryset=VLANGroup.objects.all(), required=False, label='Group', widget=APISelect(
+        api_url='/api/ipam/vlan-groups/?site_id={{site}}',
+    ))
 
 
     class Meta:
     class Meta:
         model = VLAN
         model = VLAN
-        fields = ['site', 'vid', 'name', 'status', 'role']
+        fields = ['site', 'group', 'vid', 'name', 'status', 'role']
         help_texts = {
         help_texts = {
             'site': "The site at which this VLAN exists",
             'site': "The site at which this VLAN exists",
+            'group': "VLAN group (optional)",
             'vid': "Configured VLAN ID",
             'vid': "Configured VLAN ID",
             'name': "Configured VLAN name",
             'name': "Configured VLAN name",
             'status': "Operational status of this VLAN",
             'status': "Operational status of this VLAN",
             'role': "The primary function of this VLAN",
             'role': "The primary function of this VLAN",
         }
         }
+        widgets = {
+            'site': forms.Select(attrs={'filter-for': 'group'}),
+        }
+
+    def __init__(self, *args, **kwargs):
+
+        super(VLANForm, self).__init__(*args, **kwargs)
+
+        # Limit VLAN group choices
+        if self.is_bound and self.data.get('site'):
+            self.fields['group'].queryset = VLANGroup.objects.filter(site__pk=self.data['site'])
+        elif self.initial.get('site'):
+            self.fields['group'].queryset = VLANGroup.objects.filter(site=self.initial['site'])
+        else:
+            self.fields['group'].choices = []
 
 
 
 
 class VLANFromCSVForm(forms.ModelForm):
 class VLANFromCSVForm(forms.ModelForm):
     site = forms.ModelChoiceField(queryset=Site.objects.all(), to_field_name='name',
     site = forms.ModelChoiceField(queryset=Site.objects.all(), to_field_name='name',
                                   error_messages={'invalid_choice': 'Device not found.'})
                                   error_messages={'invalid_choice': 'Device not found.'})
+    group = forms.ModelChoiceField(queryset=VLANGroup.objects.all(), required=False, to_field_name='name',
+                                   error_messages={'invalid_choice': 'VLAN group not found.'})
     status_name = forms.ChoiceField(choices=[(s[1], s[0]) for s in VLAN_STATUS_CHOICES])
     status_name = forms.ChoiceField(choices=[(s[1], s[0]) for s in VLAN_STATUS_CHOICES])
     role = forms.ModelChoiceField(queryset=Role.objects.all(), required=False, to_field_name='name',
     role = forms.ModelChoiceField(queryset=Role.objects.all(), required=False, to_field_name='name',
                                   error_messages={'invalid_choice': 'Invalid role.'})
                                   error_messages={'invalid_choice': 'Invalid role.'})
 
 
     class Meta:
     class Meta:
         model = VLAN
         model = VLAN
-        fields = ['site', 'vid', 'name', 'status_name', 'role']
+        fields = ['site', 'group', 'vid', 'name', 'status_name', 'role']
 
 
     def save(self, *args, **kwargs):
     def save(self, *args, **kwargs):
         m = super(VLANFromCSVForm, self).save(commit=False)
         m = super(VLANFromCSVForm, self).save(commit=False)
@@ -465,6 +542,11 @@ def vlan_site_choices():
     return [(s.slug, '{} ({})'.format(s.name, s.vlan_count)) for s in site_choices]
     return [(s.slug, '{} ({})'.format(s.name, s.vlan_count)) for s in site_choices]
 
 
 
 
+def vlan_group_choices():
+    group_choices = VLANGroup.objects.select_related('site').annotate(vlan_count=Count('vlans'))
+    return [(g.pk, '{} ({})'.format(g, g.vlan_count)) for g in group_choices]
+
+
 def vlan_status_choices():
 def vlan_status_choices():
     status_counts = {}
     status_counts = {}
     for status in VLAN.objects.values('status').annotate(count=Count('status')).order_by('status'):
     for status in VLAN.objects.values('status').annotate(count=Count('status')).order_by('status'):
@@ -480,6 +562,8 @@ def vlan_role_choices():
 class VLANFilterForm(forms.Form, BootstrapMixin):
 class VLANFilterForm(forms.Form, BootstrapMixin):
     site = forms.MultipleChoiceField(required=False, choices=vlan_site_choices,
     site = forms.MultipleChoiceField(required=False, choices=vlan_site_choices,
                                      widget=forms.SelectMultiple(attrs={'size': 8}))
                                      widget=forms.SelectMultiple(attrs={'size': 8}))
+    group_id = forms.MultipleChoiceField(required=False, choices=vlan_group_choices, label='VLAN Group',
+                                         widget=forms.SelectMultiple(attrs={'size': 8}))
     status = forms.MultipleChoiceField(required=False, choices=vlan_status_choices)
     status = forms.MultipleChoiceField(required=False, choices=vlan_status_choices)
     role = forms.MultipleChoiceField(required=False, choices=vlan_role_choices,
     role = forms.MultipleChoiceField(required=False, choices=vlan_role_choices,
                                      widget=forms.SelectMultiple(attrs={'size': 8}))
                                      widget=forms.SelectMultiple(attrs={'size': 8}))

+ 20 - 0
netbox/ipam/migrations/0002_vrf_add_enforce_unique.py

@@ -0,0 +1,20 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.9.7 on 2016-07-14 19:34
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('ipam', '0001_initial'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='vrf',
+            name='enforce_unique',
+            field=models.BooleanField(default=True, help_text=b'Prevent duplicate prefixes/IP addresses within this VRF', verbose_name=b'Enforce unique space'),
+        ),
+    ]

+ 38 - 0
netbox/ipam/migrations/0003_ipam_add_vlangroups.py

@@ -0,0 +1,38 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.9.7 on 2016-07-15 16:22
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0010_devicebay_installed_device_set_null'),
+        ('ipam', '0002_vrf_add_enforce_unique'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='VLANGroup',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('name', models.CharField(max_length=50)),
+                ('slug', models.SlugField()),
+                ('site', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='vlan_groups', to='dcim.Site')),
+            ],
+            options={
+                'ordering': ['site', 'name'],
+            },
+        ),
+        migrations.AddField(
+            model_name='vlan',
+            name='group',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='vlans', to='ipam.VLANGroup'),
+        ),
+        migrations.AlterUniqueTogether(
+            name='vlangroup',
+            unique_together=set([('site', 'name'), ('site', 'slug')]),
+        ),
+    ]

+ 27 - 0
netbox/ipam/migrations/0004_ipam_vlangroup_uniqueness.py

@@ -0,0 +1,27 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.9.7 on 2016-07-15 17:14
+from __future__ import unicode_literals
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('ipam', '0003_ipam_add_vlangroups'),
+    ]
+
+    operations = [
+        migrations.AlterModelOptions(
+            name='vlan',
+            options={'ordering': ['site', 'group', 'vid'], 'verbose_name': 'VLAN', 'verbose_name_plural': 'VLANs'},
+        ),
+        migrations.AlterModelOptions(
+            name='vlangroup',
+            options={'ordering': ['site', 'name'], 'verbose_name': 'VLAN group', 'verbose_name_plural': 'VLAN groups'},
+        ),
+        migrations.AlterUniqueTogether(
+            name='vlan',
+            unique_together=set([('group', 'name'), ('group', 'vid')]),
+        ),
+    ]

+ 68 - 3
netbox/ipam/models.py

@@ -1,5 +1,6 @@
 from netaddr import IPNetwork, cidr_merge
 from netaddr import IPNetwork, cidr_merge
 
 
+from django.conf import settings
 from django.core.exceptions import ValidationError
 from django.core.exceptions import ValidationError
 from django.core.urlresolvers import reverse
 from django.core.urlresolvers import reverse
 from django.core.validators import MaxValueValidator, MinValueValidator
 from django.core.validators import MaxValueValidator, MinValueValidator
@@ -45,6 +46,8 @@ class VRF(CreatedUpdatedModel):
     """
     """
     name = models.CharField(max_length=50)
     name = models.CharField(max_length=50)
     rd = models.CharField(max_length=21, unique=True, verbose_name='Route distinguisher')
     rd = models.CharField(max_length=21, unique=True, verbose_name='Route distinguisher')
+    enforce_unique = models.BooleanField(default=True, verbose_name='Enforce unique space',
+                                         help_text="Prevent duplicate prefixes/IP addresses within this VRF")
     description = models.CharField(max_length=100, blank=True)
     description = models.CharField(max_length=100, blank=True)
 
 
     class Meta:
     class Meta:
@@ -244,6 +247,15 @@ class Prefix(CreatedUpdatedModel):
     def get_absolute_url(self):
     def get_absolute_url(self):
         return reverse('ipam:prefix', args=[self.pk])
         return reverse('ipam:prefix', args=[self.pk])
 
 
+    def clean(self):
+        # Disallow host masks
+        if self.prefix.version == 4 and self.prefix.prefixlen == 32:
+            raise ValidationError("Cannot create host addresses (/32) as prefixes. These should be IPv4 addresses "
+                                  "instead.")
+        elif self.prefix.version == 6 and self.prefix.prefixlen == 128:
+            raise ValidationError("Cannot create host addresses (/128) as prefixes. These should be IPv6 addresses "
+                                  "instead.")
+
     def save(self, *args, **kwargs):
     def save(self, *args, **kwargs):
         if self.prefix:
         if self.prefix:
             # Clear host bits from prefix
             # Clear host bits from prefix
@@ -309,6 +321,21 @@ class IPAddress(CreatedUpdatedModel):
     def get_absolute_url(self):
     def get_absolute_url(self):
         return reverse('ipam:ipaddress', args=[self.pk])
         return reverse('ipam:ipaddress', args=[self.pk])
 
 
+    def clean(self):
+
+        # Enforce unique IP space if applicable
+        if self.vrf and self.vrf.enforce_unique:
+            duplicate_ips = IPAddress.objects.filter(vrf=self.vrf, address__net_host=str(self.address.ip))\
+                .exclude(pk=self.pk)
+            if duplicate_ips:
+                raise ValidationError("Duplicate IP address found in VRF {}: {}".format(self.vrf,
+                                                                                        duplicate_ips.first()))
+        elif not self.vrf and settings.ENFORCE_GLOBAL_UNIQUE:
+            duplicate_ips = IPAddress.objects.filter(vrf=None, address__net_host=str(self.address.ip))\
+                .exclude(pk=self.pk)
+            if duplicate_ips:
+                raise ValidationError("Duplicate IP address found in global table: {}".format(duplicate_ips.first()))
+
     def save(self, *args, **kwargs):
     def save(self, *args, **kwargs):
         if self.address:
         if self.address:
             # Infer address family from IPAddress object
             # Infer address family from IPAddress object
@@ -340,13 +367,41 @@ class IPAddress(CreatedUpdatedModel):
         return None
         return None
 
 
 
 
+class VLANGroup(models.Model):
+    """
+    A VLAN group is an arbitrary collection of VLANs within which VLAN IDs and names must be unique.
+    """
+    name = models.CharField(max_length=50)
+    slug = models.SlugField()
+    site = models.ForeignKey('dcim.Site', related_name='vlan_groups')
+
+    class Meta:
+        ordering = ['site', 'name']
+        unique_together = [
+            ['site', 'name'],
+            ['site', 'slug'],
+        ]
+        verbose_name = 'VLAN group'
+        verbose_name_plural = 'VLAN groups'
+
+    def __unicode__(self):
+        return '{} - {}'.format(self.site.name, self.name)
+
+    def get_absolute_url(self):
+        return "{}?group_id={}".format(reverse('ipam:vlan_list'), self.pk)
+
+
 class VLAN(CreatedUpdatedModel):
 class VLAN(CreatedUpdatedModel):
     """
     """
     A VLAN is a distinct layer two forwarding domain identified by a 12-bit integer (1-4094). Each VLAN must be assigned
     A VLAN is a distinct layer two forwarding domain identified by a 12-bit integer (1-4094). Each VLAN must be assigned
-    to a Site, however VLAN IDs need not be unique within a Site. Like Prefixes, each VLAN is assigned an operational
-    status and optionally a user-defined Role. A VLAN can have zero or more Prefixes assigned to it.
+    to a Site, however VLAN IDs need not be unique within a Site. A VLAN may optionally be assigned to a VLANGroup,
+    within which all VLAN IDs and names but be unique.
+
+    Like Prefixes, each VLAN is assigned an operational status and optionally a user-defined Role. A VLAN can have zero
+    or more Prefixes assigned to it.
     """
     """
     site = models.ForeignKey('dcim.Site', related_name='vlans', on_delete=models.PROTECT)
     site = models.ForeignKey('dcim.Site', related_name='vlans', on_delete=models.PROTECT)
+    group = models.ForeignKey('VLANGroup', related_name='vlans', blank=True, null=True, on_delete=models.PROTECT)
     vid = models.PositiveSmallIntegerField(verbose_name='ID', validators=[
     vid = models.PositiveSmallIntegerField(verbose_name='ID', validators=[
         MinValueValidator(1),
         MinValueValidator(1),
         MaxValueValidator(4094)
         MaxValueValidator(4094)
@@ -356,7 +411,11 @@ class VLAN(CreatedUpdatedModel):
     role = models.ForeignKey('Role', related_name='vlans', on_delete=models.SET_NULL, blank=True, null=True)
     role = models.ForeignKey('Role', related_name='vlans', on_delete=models.SET_NULL, blank=True, null=True)
 
 
     class Meta:
     class Meta:
-        ordering = ['site', 'vid']
+        ordering = ['site', 'group', 'vid']
+        unique_together = [
+            ['group', 'vid'],
+            ['group', 'name'],
+        ]
         verbose_name = 'VLAN'
         verbose_name = 'VLAN'
         verbose_name_plural = 'VLANs'
         verbose_name_plural = 'VLANs'
 
 
@@ -366,6 +425,12 @@ class VLAN(CreatedUpdatedModel):
     def get_absolute_url(self):
     def get_absolute_url(self):
         return reverse('ipam:vlan', args=[self.pk])
         return reverse('ipam:vlan', args=[self.pk])
 
 
+    def clean(self):
+
+        # Validate VLAN group
+        if self.group and self.group.site != self.site:
+            raise ValidationError("VLAN group must belong to the assigned site ({}).".format(self.site))
+
     def to_csv(self):
     def to_csv(self):
         return ','.join([
         return ','.join([
             self.site.name,
             self.site.name,

+ 26 - 2
netbox/ipam/tables.py

@@ -3,7 +3,7 @@ from django_tables2.utils import Accessor
 
 
 from utilities.tables import BaseTable, ToggleColumn
 from utilities.tables import BaseTable, ToggleColumn
 
 
-from .models import Aggregate, IPAddress, Prefix, RIR, Role, VLAN, VRF
+from .models import Aggregate, IPAddress, Prefix, RIR, Role, VLAN, VLANGroup, VRF
 
 
 
 
 RIR_EDIT_LINK = """
 RIR_EDIT_LINK = """
@@ -50,6 +50,12 @@ STATUS_LABEL = """
 {% endif %}
 {% endif %}
 """
 """
 
 
+VLANGROUP_EDIT_LINK = """
+{% if perms.ipam.change_vlangroup %}
+    <a href="{% url 'ipam:vlangroup_edit' pk=record.pk %}">Edit</a>
+{% endif %}
+"""
+
 
 
 #
 #
 # VRFs
 # VRFs
@@ -177,6 +183,23 @@ class IPAddressBriefTable(BaseTable):
         fields = ('address', 'device', 'interface', 'nat_inside')
         fields = ('address', 'device', 'interface', 'nat_inside')
 
 
 
 
+#
+# VLAN groups
+#
+
+class VLANGroupTable(BaseTable):
+    pk = ToggleColumn()
+    name = tables.LinkColumn(verbose_name='Name')
+    site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
+    vlan_count = tables.Column(verbose_name='VLANs')
+    slug = tables.Column(verbose_name='Slug')
+    edit = tables.TemplateColumn(template_code=VLANGROUP_EDIT_LINK, verbose_name='')
+
+    class Meta(BaseTable.Meta):
+        model = VLANGroup
+        fields = ('pk', 'name', 'site', 'vlan_count', 'slug', 'edit')
+
+
 #
 #
 # VLANs
 # VLANs
 #
 #
@@ -185,10 +208,11 @@ class VLANTable(BaseTable):
     pk = ToggleColumn()
     pk = ToggleColumn()
     vid = tables.LinkColumn('ipam:vlan', args=[Accessor('pk')], verbose_name='ID')
     vid = tables.LinkColumn('ipam:vlan', args=[Accessor('pk')], verbose_name='ID')
     site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
     site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
+    group = tables.Column(accessor=Accessor('group.name'), verbose_name='Group')
     name = tables.Column(verbose_name='Name')
     name = tables.Column(verbose_name='Name')
     status = tables.TemplateColumn(STATUS_LABEL, verbose_name='Status')
     status = tables.TemplateColumn(STATUS_LABEL, verbose_name='Status')
     role = tables.Column(verbose_name='Role')
     role = tables.Column(verbose_name='Role')
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = VLAN
         model = VLAN
-        fields = ('pk', 'vid', 'site', 'name', 'status', 'role')
+        fields = ('pk', 'vid', 'site', 'group', 'name', 'status', 'role')

+ 6 - 0
netbox/ipam/urls.py

@@ -58,6 +58,12 @@ urlpatterns = [
     url(r'^ip-addresses/(?P<pk>\d+)/edit/$', views.IPAddressEditView.as_view(), name='ipaddress_edit'),
     url(r'^ip-addresses/(?P<pk>\d+)/edit/$', views.IPAddressEditView.as_view(), name='ipaddress_edit'),
     url(r'^ip-addresses/(?P<pk>\d+)/delete/$', views.IPAddressDeleteView.as_view(), name='ipaddress_delete'),
     url(r'^ip-addresses/(?P<pk>\d+)/delete/$', views.IPAddressDeleteView.as_view(), name='ipaddress_delete'),
 
 
+    # VLAN groups
+    url(r'^vlan-groups/$', views.VLANGroupListView.as_view(), name='vlangroup_list'),
+    url(r'^vlan-groups/add/$', views.VLANGroupEditView.as_view(), name='vlangroup_add'),
+    url(r'^vlan-groups/delete/$', views.VLANGroupBulkDeleteView.as_view(), name='vlangroup_bulk_delete'),
+    url(r'^vlan-groups/(?P<pk>\d+)/edit/$', views.VLANGroupEditView.as_view(), name='vlangroup_edit'),
+
     # VLANs
     # VLANs
     url(r'^vlans/$', views.VLANListView.as_view(), name='vlan_list'),
     url(r'^vlans/$', views.VLANListView.as_view(), name='vlan_list'),
     url(r'^vlans/add/$', views.VLANEditView.as_view(), name='vlan_add'),
     url(r'^vlans/add/$', views.VLANEditView.as_view(), name='vlan_add'),

+ 28 - 1
netbox/ipam/views.py

@@ -12,7 +12,7 @@ from utilities.views import (
 )
 )
 
 
 from . import filters, forms, tables
 from . import filters, forms, tables
-from .models import Aggregate, IPAddress, Prefix, RIR, Role, VLAN, VRF
+from .models import Aggregate, IPAddress, Prefix, RIR, Role, VLAN, VLANGroup, VRF
 
 
 
 
 def add_available_prefixes(parent, prefix_list):
 def add_available_prefixes(parent, prefix_list):
@@ -483,6 +483,33 @@ class IPAddressBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     default_redirect_url = 'ipam:ipaddress_list'
     default_redirect_url = 'ipam:ipaddress_list'
 
 
 
 
+#
+# VLAN groups
+#
+
+class VLANGroupListView(ObjectListView):
+    queryset = VLANGroup.objects.annotate(vlan_count=Count('vlans'))
+    filter = filters.VLANGroupFilter
+    filter_form = forms.VLANGroupFilterForm
+    table = tables.VLANGroupTable
+    edit_permissions = ['ipam.change_vlangroup', 'ipam.delete_vlangroup']
+    template_name = 'ipam/vlangroup_list.html'
+
+
+class VLANGroupEditView(PermissionRequiredMixin, ObjectEditView):
+    permission_required = 'ipam.change_vlangroup'
+    model = VLANGroup
+    form_class = forms.VLANGroupForm
+    cancel_url = 'ipam:vlangroup_list'
+
+
+class VLANGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
+    permission_required = 'ipam.delete_vlangroup'
+    cls = VLANGroup
+    form = forms.VLANGroupBulkDeleteForm
+    default_redirect_url = 'ipam:vlangroup_list'
+
+
 #
 #
 # VLANs
 # VLANs
 #
 #

+ 4 - 0
netbox/netbox/configuration.example.py

@@ -82,3 +82,7 @@ BANNER_BOTTOM = ''
 # When determining the primary IP address for a device, IPv6 is preferred over IPv4 by default. Set this to True to
 # When determining the primary IP address for a device, IPv6 is preferred over IPv4 by default. Set this to True to
 # prefer IPv4 instead.
 # prefer IPv4 instead.
 PREFER_IPV4 = False
 PREFER_IPV4 = False
+
+# Enforcement of unique IP space can be toggled on a per-VRF basis. To enforce unique IP space within the global table
+# (all prefixes and IP addresses not assigned to a VRF), set ENFORCE_GLOBAL_UNIQUE to True.
+ENFORCE_GLOBAL_UNIQUE = False

+ 2 - 1
netbox/netbox/settings.py

@@ -12,7 +12,7 @@ except ImportError:
                                "the documentation.")
                                "the documentation.")
 
 
 
 
-VERSION = '1.2.2'
+VERSION = '1.3.0'
 
 
 # Import local configuration
 # Import local configuration
 for setting in ['ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY']:
 for setting in ['ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY']:
@@ -41,6 +41,7 @@ SHORT_DATETIME_FORMAT = getattr(configuration, 'SHORT_DATETIME_FORMAT', 'Y-m-d H
 BANNER_TOP = getattr(configuration, 'BANNER_TOP', False)
 BANNER_TOP = getattr(configuration, 'BANNER_TOP', False)
 BANNER_BOTTOM = getattr(configuration, 'BANNER_BOTTOM', False)
 BANNER_BOTTOM = getattr(configuration, 'BANNER_BOTTOM', False)
 PREFER_IPV4 = getattr(configuration, 'PREFER_IPV4', False)
 PREFER_IPV4 = getattr(configuration, 'PREFER_IPV4', False)
+ENFORCE_GLOBAL_UNIQUE = getattr(configuration, 'ENFORCE_GLOBAL_UNIQUE', False)
 CSRF_TRUSTED_ORIGINS = ALLOWED_HOSTS
 CSRF_TRUSTED_ORIGINS = ALLOWED_HOSTS
 
 
 # Attempt to import LDAP configuration if it has been defined
 # Attempt to import LDAP configuration if it has been defined

+ 3 - 1
netbox/netbox/urls.py

@@ -2,10 +2,12 @@ from django.conf.urls import include, url
 from django.contrib import admin
 from django.contrib import admin
 from django.views.defaults import page_not_found
 from django.views.defaults import page_not_found
 
 
-from views import home, trigger_500
+from views import home, trigger_500, handle_500
 from users.views import login, logout
 from users.views import login, logout
 
 
 
 
+handler500 = handle_500
+
 urlpatterns = [
 urlpatterns = [
 
 
     # Default page
     # Default page

+ 10 - 5
netbox/netbox/views.py

@@ -1,9 +1,6 @@
-from markdown import markdown
+import sys
 
 
-from django.conf import settings
-from django.http import Http404
 from django.shortcuts import render
 from django.shortcuts import render
-from django.utils.safestring import mark_safe
 
 
 from circuits.models import Provider, Circuit
 from circuits.models import Provider, Circuit
 from dcim.models import Site, Rack, Device, ConsolePort, PowerPort, InterfaceConnection
 from dcim.models import Site, Rack, Device, ConsolePort, PowerPort, InterfaceConnection
@@ -47,6 +44,14 @@ def home(request):
 
 
 def trigger_500(request):
 def trigger_500(request):
     """Hot-wired method of triggering a server error to test reporting."""
     """Hot-wired method of triggering a server error to test reporting."""
-
     raise Exception("Congratulations, you've triggered an exception! Go tell all your friends what an exceptional "
     raise Exception("Congratulations, you've triggered an exception! Go tell all your friends what an exceptional "
                     "person you are.")
                     "person you are.")
+
+
+def handle_500(request):
+    """Custom server error handler"""
+    type_, error, traceback = sys.exc_info()
+    return render(request, '500.html', {
+        'exception': str(type_),
+        'error': error,
+    }, status=500)

+ 16 - 0
netbox/project-static/css/base.css

@@ -225,6 +225,22 @@ ul.rack li.h41u { height: 820px; }
 ul.rack li.h41u a, ul.rack li.h41u span { padding: 400px 0; }
 ul.rack li.h41u a, ul.rack li.h41u span { padding: 400px 0; }
 ul.rack li.h42u { height: 840px; }
 ul.rack li.h42u { height: 840px; }
 ul.rack li.h42u a, ul.rack li.h42u span { padding: 410px 0; }
 ul.rack li.h42u a, ul.rack li.h42u span { padding: 410px 0; }
+ul.rack li.h43u { height: 860px; }
+ul.rack li.h43u a, ul.rack li.h43u span { padding: 420px 0; }
+ul.rack li.h44u { height: 880px; }
+ul.rack li.h44u a, ul.rack li.h44u span { padding: 430px 0; }
+ul.rack li.h45u { height: 900px; }
+ul.rack li.h45u a, ul.rack li.h45u span { padding: 440px 0; }
+ul.rack li.h46u { height: 920px; }
+ul.rack li.h46u a, ul.rack li.h46u span { padding: 450px 0; }
+ul.rack li.h47u { height: 940px; }
+ul.rack li.h47u a, ul.rack li.h47u span { padding: 460px 0; }
+ul.rack li.h48u { height: 960px; }
+ul.rack li.h48u a, ul.rack li.h48u span { padding: 470px 0; }
+ul.rack li.h49u { height: 980px; }
+ul.rack li.h49u a, ul.rack li.h49u span { padding: 480px 0; }
+ul.rack li.h50u { height: 1000px; }
+ul.rack li.h50u a, ul.rack li.h50u span { padding: 490px 0; }
 ul.rack li.occupied a {
 ul.rack li.occupied a {
     color: #ffffff;
     color: #ffffff;
     display: block;
     display: block;

+ 8 - 2
netbox/templates/500.html

@@ -12,13 +12,19 @@
         <div class="col-md-4 col-md-offset-4">
         <div class="col-md-4 col-md-offset-4">
             <div class="panel panel-danger" style="margin-top: 200px">
             <div class="panel panel-danger" style="margin-top: 200px">
                 <div class="panel-heading">
                 <div class="panel-heading">
-                    <strong>Server Error</strong>
+                    <strong>
+                        <i class="glyphicon glyphicon-warning-sign"></i>
+                        Server Error
+                    </strong>
                 </div>
                 </div>
                 <div class="panel-body">
                 <div class="panel-body">
                     <p>There was a problem with your request. This error has been logged and administrative staff have
                     <p>There was a problem with your request. This error has been logged and administrative staff have
                     been notified. Please return to the home page and try again.</p>
                     been notified. Please return to the home page and try again.</p>
                     <p>If you are responsible for this installation, please consider
                     <p>If you are responsible for this installation, please consider
-                    <a href="https://github.com/digitalocean/netbox/issues">filing a bug report</a>.</p>
+                    <a href="https://github.com/digitalocean/netbox/issues">filing a bug report</a>. Additional
+                    information is provided below:</p>
+                    <pre><strong>{{ exception }}</strong><br />
+{{ error }}</pre>
                     <div class="text-right">
                     <div class="text-right">
                         <a href="/" class="btn btn-primary">Home Page</a>
                         <a href="/" class="btn btn-primary">Home Page</a>
                     </div>
                     </div>

+ 13 - 10
netbox/templates/_base.html

@@ -110,7 +110,7 @@
                             {% endif %}
                             {% endif %}
                         </ul>
                         </ul>
                     </li>
                     </li>
-                    <li class="dropdown{% if request.path|startswith:'/ipam/' and not request.path|startswith:'/ipam/vlans/' %} active{% endif %}">
+                    <li class="dropdown{% if request.path|startswith:'/ipam/' and not request.path|startswith:'/ipam/vlan' %} active{% endif %}">
                         <a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">IP Space <span class="caret"></span></a>
                         <a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">IP Space <span class="caret"></span></a>
                         <ul class="dropdown-menu">
                         <ul class="dropdown-menu">
                             <li><a href="{% url 'ipam:ipaddress_list' %}"><i class="glyphicon glyphicon-search" aria-hidden="true"></i> IP Addresses</a></li>
                             <li><a href="{% url 'ipam:ipaddress_list' %}"><i class="glyphicon glyphicon-search" aria-hidden="true"></i> IP Addresses</a></li>
@@ -156,17 +156,20 @@
                             {% endif %}
                             {% endif %}
                         </ul>
                         </ul>
                     </li>
                     </li>
-                    <li class="dropdown{% if request.path|startswith:'/ipam/vlans/' %} active{% endif %}">
-                        {% if perms.ipam.add_vlan %}
-                            <a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">VLANs <span class="caret"></span></a>
-                            <ul class="dropdown-menu">
-                                <li><a href="{% url 'ipam:vlan_list' %}"><i class="glyphicon glyphicon-search" aria-hidden="true"></i> VLANs</a></li>
+                    <li class="dropdown{% if request.path|startswith:'/ipam/vlan' %} active{% endif %}">
+                        <a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">VLANs <span class="caret"></span></a>
+                        <ul class="dropdown-menu">
+                            <li><a href="{% url 'ipam:vlan_list' %}"><i class="glyphicon glyphicon-search" aria-hidden="true"></i> VLANs</a></li>
+                            {% if perms.ipam.add_vlan %}
                                 <li><a href="{% url 'ipam:vlan_add' %}"><i class="glyphicon glyphicon-plus" aria-hidden="true"></i> Add a VLAN</a></li>
                                 <li><a href="{% url 'ipam:vlan_add' %}"><i class="glyphicon glyphicon-plus" aria-hidden="true"></i> Add a VLAN</a></li>
                                 <li><a href="{% url 'ipam:vlan_import' %}"><i class="glyphicon glyphicon-import" aria-hidden="true"></i> Import VLANs</a></li>
                                 <li><a href="{% url 'ipam:vlan_import' %}"><i class="glyphicon glyphicon-import" aria-hidden="true"></i> Import VLANs</a></li>
-                            </ul>
-                        {% else %}
-                            <a href="{% url 'ipam:vlan_list' %}">VLANs</a>
-                        {% endif %}
+                            {% endif %}
+                            <li class="divider"></li>
+                            <li><a href="{% url 'ipam:vlangroup_list' %}"><i class="glyphicon glyphicon-search" aria-hidden="true"></i> VLAN Groups</a></li>
+                            {% if perms.ipam.add_vlangroup %}
+                                <li><a href="{% url 'ipam:vlangroup_add' %}"><i class="glyphicon glyphicon-plus" aria-hidden="true"></i> Add a VLAN Group</a></li>
+                            {% endif %}
+                        </ul>
                     </li>
                     </li>
                     <li class="dropdown{% if request.path|startswith:'/circuits/' %} active{% endif %}">
                     <li class="dropdown{% if request.path|startswith:'/circuits/' %} active{% endif %}">
                         <a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Circuits <span class="caret"></span></a>
                         <a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Circuits <span class="caret"></span></a>

+ 1 - 1
netbox/templates/dcim/device_import.html

@@ -5,7 +5,7 @@
 {% block title %}Device Import{% endblock %}
 {% block title %}Device Import{% endblock %}
 
 
 {% block content %}
 {% block content %}
-<h1>Device Import</h1>
+{% include 'dcim/inc/_device_import_header.html' %}
 <div class="row">
 <div class="row">
 	<div class="col-md-12">
 	<div class="col-md-12">
 		<form action="." method="post" class="form">
 		<form action="." method="post" class="form">

+ 75 - 0
netbox/templates/dcim/device_import_child.html

@@ -0,0 +1,75 @@
+{% extends '_base.html' %}
+{% load render_table from django_tables2 %}
+{% load form_helpers %}
+
+{% block title %}Device Import{% endblock %}
+
+{% block content %}
+{% include 'dcim/inc/_device_import_header.html' with active_tab='child_import' %}
+<div class="row">
+	<div class="col-md-12">
+		<form action="." method="post" class="form">
+		    {% csrf_token %}
+		    {% render_form form %}
+            <div class="form-group">
+                <button type="submit" class="btn btn-primary">Submit</button>
+                <a href="{% url obj_list_url %}" class="btn btn-default">Cancel</a>
+            </div>
+		</form>
+		<h4>CSV Format</h4>
+		<table class="table">
+			<thead>
+				<tr>
+					<th>Field</th>
+					<th>Description</th>
+					<th>Example</th>
+				</tr>
+			</thead>
+			<tbody>
+				<tr>
+					<td>Name</td>
+					<td>Device name (optional)</td>
+					<td>Blade12</td>
+				</tr>
+				<tr>
+					<td>Device role</td>
+					<td>Functional role of device</td>
+					<td>Blade Server</td>
+				</tr>
+				<tr>
+					<td>Device manufacturer</td>
+					<td>Hardware manufacturer</td>
+					<td>Dell</td>
+				</tr>
+				<tr>
+					<td>Device model</td>
+					<td>Hardware model</td>
+					<td>BS2000T</td>
+				</tr>
+				<tr>
+					<td>Platform</td>
+					<td>Software running on device (optional)</td>
+					<td>Linux</td>
+				</tr>
+				<tr>
+					<td>Serial</td>
+					<td>Serial number (optional)</td>
+					<td>CAB00577291</td>
+				</tr>
+				<tr>
+					<td>Parent device</td>
+					<td>Parent device</td>
+					<td>Server101</td>
+				</tr>
+				<tr>
+					<td>Device bay</td>
+					<td>Device bay name</td>
+					<td>Slot 4</td>
+				</tr>
+			</tbody>
+		</table>
+		<h4>Example</h4>
+		<pre>Blade12,Blade Server,Dell,BS2000T,Linux,CAB00577291,Server101,Slot4</pre>
+	</div>
+</div>
+{% endblock %}

+ 5 - 0
netbox/templates/dcim/inc/_device_import_header.html

@@ -0,0 +1,5 @@
+<h1>Device Import</h1>
+<ul class="nav nav-tabs" style="margin-bottom: 20px">
+    <li role="presentation"{% if not active_tab %} class="active"{% endif %}><a href="{% url 'dcim:device_import' %}">Racked Devices</a></li>
+    <li role="presentation"{% if active_tab == 'child_import' %} class="active"{% endif %}><a href="{% url 'dcim:device_import_child' %}">Child Devices</a></li>
+</ul>

+ 11 - 1
netbox/templates/ipam/prefix_import.html

@@ -43,6 +43,16 @@
 					<td>Name of assigned site (optional)</td>
 					<td>Name of assigned site (optional)</td>
 					<td>HQ</td>
 					<td>HQ</td>
 				</tr>
 				</tr>
+				<tr>
+					<td>VLAN Group</td>
+					<td>Name of group for VLAN selection (optional)</td>
+					<td>Customers</td>
+				</tr>
+				<tr>
+					<td>VLAN ID</td>
+					<td>Numeric VLAN ID (optional)</td>
+					<td>801</td>
+				</tr>
 				<tr>
 				<tr>
 					<td>Status</td>
 					<td>Status</td>
 					<td>Current status</td>
 					<td>Current status</td>
@@ -61,7 +71,7 @@
 			</tbody>
 			</tbody>
 		</table>
 		</table>
 		<h4>Example</h4>
 		<h4>Example</h4>
-		<pre>192.168.42.0/24,65000:123,HQ,Active,Customer,7th floor WiFi</pre>
+		<pre>192.168.42.0/24,65000:123,HQ,Customers,801,Active,Customer,7th floor WiFi</pre>
 	</div>
 	</div>
 </div>
 </div>
 {% endblock %}
 {% endblock %}

+ 10 - 0
netbox/templates/ipam/vlan.html

@@ -51,6 +51,16 @@
                     <td>Site</td>
                     <td>Site</td>
                     <td><a href="{% url 'dcim:site' slug=vlan.site.slug %}">{{ vlan.site }}</a></td>
                     <td><a href="{% url 'dcim:site' slug=vlan.site.slug %}">{{ vlan.site }}</a></td>
                 </tr>
                 </tr>
+                <tr>
+                    <td>Group</td>
+                    <td>
+                        {% if vlan.group %}
+                            <a href="{{ vlan.group.get_absolute_url }}">{{ vlan.group.name }}</a>
+                        {% else %}
+                            <span class="text-muted">None</span>
+                        {% endif %}
+                    </td>
+                </tr>
                 <tr>
                 <tr>
                     <td>VLAN ID</td>
                     <td>VLAN ID</td>
                     <td>{{ vlan.vid }}</td>
                     <td>{{ vlan.vid }}</td>

+ 6 - 1
netbox/templates/ipam/vlan_import.html

@@ -33,6 +33,11 @@
 					<td>Name of assigned site</td>
 					<td>Name of assigned site</td>
 					<td>LAS2</td>
 					<td>LAS2</td>
 				</tr>
 				</tr>
+				<tr>
+					<td>Group</td>
+					<td>Name of VLAN group (optional)</td>
+					<td>Backend Network</td>
+				</tr>
 				<tr>
 				<tr>
 					<td>ID</td>
 					<td>ID</td>
 					<td>Configured VLAN ID</td>
 					<td>Configured VLAN ID</td>
@@ -56,7 +61,7 @@
 			</tbody>
 			</tbody>
 		</table>
 		</table>
 		<h4>Example</h4>
 		<h4>Example</h4>
-		<pre>LAS2,1400,Cameras,Active,Security</pre>
+		<pre>LAS2,Backend Network,1400,Cameras,Active,Security</pre>
 	</div>
 	</div>
 </div>
 </div>
 {% endblock %}
 {% endblock %}

+ 24 - 0
netbox/templates/ipam/vlangroup_list.html

@@ -0,0 +1,24 @@
+{% extends '_base.html' %}
+{% load helpers %}
+
+{% block title %}VLAN Groups{% endblock %}
+
+{% block content %}
+<div class="pull-right">
+    {% if perms.ipam.add_vlangroup %}
+        <a href="{% url 'ipam:vlangroup_add' %}" class="btn btn-primary">
+            <span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
+            Add a VLAN group
+        </a>
+    {% endif %}
+</div>
+<h1>VLAN Groups</h1>
+<div class="row">
+	<div class="col-md-9">
+        {% include 'utilities/obj_table.html' with bulk_delete_url='ipam:vlangroup_bulk_delete' %}
+    </div>
+    <div class="col-md-3">
+		{% include 'inc/filter_panel.html' %}
+    </div>
+</div>
+{% endblock %}

+ 10 - 0
netbox/templates/ipam/vrf.html

@@ -30,6 +30,16 @@
                     <td>Route Distinguisher</td>
                     <td>Route Distinguisher</td>
                     <td>{{ vrf.rd }}</td>
                     <td>{{ vrf.rd }}</td>
                 </tr>
                 </tr>
+                <tr>
+                    <td>Enforce Uniqueness</td>
+                    <td>
+                        {% if vrf.enforce_unique %}
+                            <i class="glyphicon glyphicon-ok text-success" title="Yes"></i>
+                        {% else %}
+                            <i class="glyphicon glyphicon-remove text-danger" title="No"></i>
+                        {% endif %}
+                    </td>
+                </tr>
                 <tr>
                 <tr>
                     <td>Description</td>
                     <td>Description</td>
                     <td>
                     <td>

+ 6 - 1
netbox/templates/ipam/vrf_import.html

@@ -38,6 +38,11 @@
 					<td>Route distinguisher</td>
 					<td>Route distinguisher</td>
 					<td>65000:123456</td>
 					<td>65000:123456</td>
 				</tr>
 				</tr>
+				<tr>
+					<td>Enforce uniqueness</td>
+					<td>Prevent duplicate prefixes/IP addresses</td>
+					<td>True</td>
+				</tr>
 				<tr>
 				<tr>
 					<td>Description</td>
 					<td>Description</td>
 					<td>Short description (optional)</td>
 					<td>Short description (optional)</td>
@@ -46,7 +51,7 @@
 			</tbody>
 			</tbody>
 		</table>
 		</table>
 		<h4>Example</h4>
 		<h4>Example</h4>
-		<pre>Customer_ABC,65000:123456,Native VRF for customer ABC</pre>
+		<pre>Customer_ABC,65000:123456,True,Native VRF for customer ABC</pre>
 	</div>
 	</div>
 </div>
 </div>
 {% endblock %}
 {% endblock %}