Merge pull request #324 from digitalocean/develop

Release v1.3.0
This commit is contained in:
Jeremy Stretch 2016-07-18 13:49:08 -04:00 committed by GitHub
commit 5ba5e8def9
36 changed files with 751 additions and 74 deletions

View File

@ -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.

View File

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

View File

@ -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 { device_bay = DeviceBay.objects.get(device=parent, name=device_bay_name)
'front': 0, if device_bay.installed_device:
'rear': 1, self.add_error('device_bay_name',
}[face.lower()] "Device bay ({} {}) is already occupied".format(parent, device_bay_name))
except KeyError: else:
raise forms.ValidationError('Invalid rack face ({}); must be "front" or "rear".'.format(face)) self.instance.parent_bay = device_bay
return face 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')

View File

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

View File

@ -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
rack_face = self.face if not self.device_type.is_full_depth else None
except DeviceType.DoesNotExist:
raise ValidationError("Must specify device type.")
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']

View File

@ -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'),

View File

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

View File

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

View File

@ -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):

View File

@ -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'),

View File

@ -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.models import VRF, Role, RIR, Aggregate, Prefix, IPAddress, VLAN, VLANGroup
from ipam.filters import AggregateFilter, PrefixFilter, IPAddressFilter, VLANFilter, VRFFilter 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):

View File

@ -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',

View File

@ -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}))

View File

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

View File

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

View File

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

View File

@ -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 to a Site, however VLAN IDs need not be unique within a Site. A VLAN may optionally be assigned to a VLANGroup,
status and optionally a user-defined Role. A VLAN can have zero or more Prefixes assigned to it. 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,

View File

@ -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')

View File

@ -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'),

View File

@ -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
# #

View File

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

View File

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

View File

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

View File

@ -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)

View File

@ -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;

View File

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

View File

@ -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 %}"> <li class="dropdown{% if request.path|startswith:'/ipam/vlan' %} 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>
<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">
<ul class="dropdown-menu"> <li><a href="{% url 'ipam:vlan_list' %}"><i class="glyphicon glyphicon-search" aria-hidden="true"></i> VLANs</a></li>
<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> {% endif %}
{% else %} <li class="divider"></li>
<a href="{% url 'ipam:vlan_list' %}">VLANs</a> <li><a href="{% url 'ipam:vlangroup_list' %}"><i class="glyphicon glyphicon-search" aria-hidden="true"></i> VLAN Groups</a></li>
{% endif %} {% 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>

View File

@ -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">

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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