Parcourir la source

Merge pull request #309 from digitalocean/vlan-groups

Closes #111: Implement VLAN groups
Jeremy Stretch il y a 8 ans
Parent
commit
a9ab0a012f

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

+ 21 - 2
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
 
 
 
 
 #
 #
@@ -74,16 +74,35 @@ class AggregateNestedSerializer(AggregateSerializer):
 
 
 
 
 #
 #
+# 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',

+ 54 - 2
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,
 )
 )
 
 
 
 
@@ -408,21 +408,66 @@ class IPAddressFilterForm(forms.Form, BootstrapMixin):
 
 
 
 
 #
 #
+# 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):
@@ -465,6 +510,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 +530,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}))

+ 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')]),
+        ),
+    ]

+ 41 - 3
netbox/ipam/models.py

@@ -358,13 +358,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)
@@ -374,7 +402,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'
 
 
@@ -384,6 +416,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.vlan_group and self.vlan_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
@@ -178,6 +184,23 @@ class IPAddressBriefTable(BaseTable):
 
 
 
 
 #
 #
+# 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):
@@ -484,6 +484,33 @@ class IPAddressBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 
 
 
 
 #
 #
+# 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
 #
 #
 
 

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

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