diff --git a/netbox/ipam/admin.py b/netbox/ipam/admin.py index 51ecff043..8668aeb77 100644 --- a/netbox/ipam/admin.py +++ b/netbox/ipam/admin.py @@ -1,7 +1,7 @@ from django.contrib import admin 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') +@admin.register(VLANGroup) +class VLANGroupAdmin(admin.ModelAdmin): + list_display = ['name', 'site', 'slug'] + prepopulated_fields = { + 'slug': ['name'], + } + + @admin.register(VLAN) class VLANAdmin(admin.ModelAdmin): list_display = ['site', 'vid', 'name', 'status', 'role'] diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index 144ea5482..c3d442fdf 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -1,7 +1,7 @@ from rest_framework import serializers 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 # @@ -73,17 +73,36 @@ class AggregateNestedSerializer(AggregateSerializer): 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 # class VLANSerializer(serializers.ModelSerializer): site = SiteNestedSerializer() + group = VLANGroupNestedSerializer() role = RoleNestedSerializer() class Meta: model = VLAN - fields = ['id', 'site', 'vid', 'name', 'status', 'role', 'display_name'] + fields = ['id', 'site', 'group', 'vid', 'name', 'status', 'role', 'display_name'] class VLANNestedSerializer(VLANSerializer): diff --git a/netbox/ipam/api/urls.py b/netbox/ipam/api/urls.py index 016e7110b..0c0ac9495 100644 --- a/netbox/ipam/api/urls.py +++ b/netbox/ipam/api/urls.py @@ -29,6 +29,10 @@ urlpatterns = [ url(r'^ip-addresses/$', IPAddressListView.as_view(), name='ipaddress_list'), url(r'^ip-addresses/(?P\d+)/$', IPAddressDetailView.as_view(), name='ipaddress_detail'), + # VLAN groups + url(r'^vlan-groups/$', VLANGroupListView.as_view(), name='vlangroup_list'), + url(r'^vlan-groups/(?P\d+)/$', VLANGroupDetailView.as_view(), name='vlangroup_detail'), + # VLANs url(r'^vlans/$', VLANListView.as_view(), name='vlan_list'), url(r'^vlans/(?P\d+)/$', VLANDetailView.as_view(), name='vlan_detail'), diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index 018e4f366..30a15e218 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -1,18 +1,22 @@ 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 +# +# VRFs +# + class VRFListView(generics.ListAPIView): """ List all VRFs """ queryset = VRF.objects.all() serializer_class = serializers.VRFSerializer - filter_class = VRFFilter + filter_class = filters.VRFFilter class VRFDetailView(generics.RetrieveAPIView): @@ -23,6 +27,10 @@ class VRFDetailView(generics.RetrieveAPIView): serializer_class = serializers.VRFSerializer +# +# Roles +# + class RoleListView(generics.ListAPIView): """ List all roles @@ -39,6 +47,10 @@ class RoleDetailView(generics.RetrieveAPIView): serializer_class = serializers.RoleSerializer +# +# RIRs +# + class RIRListView(generics.ListAPIView): """ List all RIRs @@ -55,13 +67,17 @@ class RIRDetailView(generics.RetrieveAPIView): serializer_class = serializers.RIRSerializer +# +# Aggregates +# + class AggregateListView(generics.ListAPIView): """ List aggregates (filterable) """ queryset = Aggregate.objects.select_related('rir') serializer_class = serializers.AggregateSerializer - filter_class = AggregateFilter + filter_class = filters.AggregateFilter class AggregateDetailView(generics.RetrieveAPIView): @@ -72,13 +88,17 @@ class AggregateDetailView(generics.RetrieveAPIView): serializer_class = serializers.AggregateSerializer +# +# Prefixes +# + class PrefixListView(generics.ListAPIView): """ List prefixes (filterable) """ queryset = Prefix.objects.select_related('site', 'vrf', 'vlan', 'role') serializer_class = serializers.PrefixSerializer - filter_class = PrefixFilter + filter_class = filters.PrefixFilter class PrefixDetailView(generics.RetrieveAPIView): @@ -89,6 +109,10 @@ class PrefixDetailView(generics.RetrieveAPIView): serializer_class = serializers.PrefixSerializer +# +# IP addresses +# + class IPAddressListView(generics.ListAPIView): """ List IP addresses (filterable) @@ -96,7 +120,7 @@ class IPAddressListView(generics.ListAPIView): queryset = IPAddress.objects.select_related('vrf', 'interface__device', 'nat_inside')\ .prefetch_related('nat_outside') serializer_class = serializers.IPAddressSerializer - filter_class = IPAddressFilter + filter_class = filters.IPAddressFilter class IPAddressDetailView(generics.RetrieveAPIView): @@ -108,13 +132,38 @@ class IPAddressDetailView(generics.RetrieveAPIView): 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): """ List VLANs (filterable) """ queryset = VLAN.objects.select_related('site', 'role') serializer_class = serializers.VLANSerializer - filter_class = VLANFilter + filter_class = filters.VLANFilter class VLANDetailView(generics.RetrieveAPIView): diff --git a/netbox/ipam/filters.py b/netbox/ipam/filters.py index 56e2cbdd2..ef87bbaa1 100644 --- a/netbox/ipam/filters.py +++ b/netbox/ipam/filters.py @@ -4,7 +4,7 @@ from netaddr.core import AddrFormatError 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): @@ -176,6 +176,24 @@ class IPAddressFilter(django_filters.FilterSet): 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): site_id = django_filters.ModelMultipleChoiceFilter( name='site', @@ -188,6 +206,17 @@ class VLANFilter(django_filters.FilterSet): to_field_name='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='name', lookup_type='icontains', diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 607ba1254..4cff5572f 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -9,7 +9,7 @@ from utilities.forms 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, ) @@ -407,22 +407,67 @@ class IPAddressFilterForm(forms.Form, BootstrapMixin): 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 # 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: model = VLAN - fields = ['site', 'vid', 'name', 'status', 'role'] + fields = ['site', 'group', 'vid', 'name', 'status', 'role'] help_texts = { 'site': "The site at which this VLAN exists", + 'group': "VLAN group (optional)", 'vid': "Configured VLAN ID", 'name': "Configured VLAN name", 'status': "Operational status 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): @@ -465,6 +510,11 @@ def vlan_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(): status_counts = {} 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): site = forms.MultipleChoiceField(required=False, choices=vlan_site_choices, 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) role = forms.MultipleChoiceField(required=False, choices=vlan_role_choices, widget=forms.SelectMultiple(attrs={'size': 8})) diff --git a/netbox/ipam/migrations/0003_ipam_add_vlangroups.py b/netbox/ipam/migrations/0003_ipam_add_vlangroups.py new file mode 100644 index 000000000..2e7157fe1 --- /dev/null +++ b/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')]), + ), + ] diff --git a/netbox/ipam/migrations/0004_ipam_vlangroup_uniqueness.py b/netbox/ipam/migrations/0004_ipam_vlangroup_uniqueness.py new file mode 100644 index 000000000..fef5ec0b3 --- /dev/null +++ b/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')]), + ), + ] diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index 447557b4e..bfac967fc 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -358,13 +358,41 @@ class IPAddress(CreatedUpdatedModel): 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): """ 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) + group = models.ForeignKey('VLANGroup', related_name='vlans', blank=True, null=True, on_delete=models.PROTECT) vid = models.PositiveSmallIntegerField(verbose_name='ID', validators=[ MinValueValidator(1), 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) class Meta: - ordering = ['site', 'vid'] + ordering = ['site', 'group', 'vid'] + unique_together = [ + ['group', 'vid'], + ['group', 'name'], + ] verbose_name = 'VLAN' verbose_name_plural = 'VLANs' @@ -384,6 +416,12 @@ class VLAN(CreatedUpdatedModel): def get_absolute_url(self): 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): return ','.join([ self.site.name, diff --git a/netbox/ipam/tables.py b/netbox/ipam/tables.py index 2267b5deb..f30906255 100644 --- a/netbox/ipam/tables.py +++ b/netbox/ipam/tables.py @@ -3,7 +3,7 @@ from django_tables2.utils import Accessor 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 = """ @@ -50,6 +50,12 @@ STATUS_LABEL = """ {% endif %} """ +VLANGROUP_EDIT_LINK = """ +{% if perms.ipam.change_vlangroup %} + Edit +{% endif %} +""" + # # VRFs @@ -177,6 +183,23 @@ class IPAddressBriefTable(BaseTable): 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 # @@ -185,10 +208,11 @@ class VLANTable(BaseTable): pk = ToggleColumn() vid = tables.LinkColumn('ipam:vlan', args=[Accessor('pk')], verbose_name='ID') 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') status = tables.TemplateColumn(STATUS_LABEL, verbose_name='Status') role = tables.Column(verbose_name='Role') class Meta(BaseTable.Meta): model = VLAN - fields = ('pk', 'vid', 'site', 'name', 'status', 'role') + fields = ('pk', 'vid', 'site', 'group', 'name', 'status', 'role') diff --git a/netbox/ipam/urls.py b/netbox/ipam/urls.py index 3f425eddd..22c4cd512 100644 --- a/netbox/ipam/urls.py +++ b/netbox/ipam/urls.py @@ -58,6 +58,12 @@ urlpatterns = [ url(r'^ip-addresses/(?P\d+)/edit/$', views.IPAddressEditView.as_view(), name='ipaddress_edit'), url(r'^ip-addresses/(?P\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\d+)/edit/$', views.VLANGroupEditView.as_view(), name='vlangroup_edit'), + # VLANs url(r'^vlans/$', views.VLANListView.as_view(), name='vlan_list'), url(r'^vlans/add/$', views.VLANEditView.as_view(), name='vlan_add'), diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 1db9a6255..7119a8209 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -12,7 +12,7 @@ from utilities.views import ( ) 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): @@ -483,6 +483,33 @@ class IPAddressBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): 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 # diff --git a/netbox/templates/_base.html b/netbox/templates/_base.html index a6ab34c26..fedbd43bf 100644 --- a/netbox/templates/_base.html +++ b/netbox/templates/_base.html @@ -110,7 +110,7 @@ {% endif %} - -