From 85739a11d82d466bebbcc8b442c7e9aa23142d57 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 14 Jul 2016 15:22:14 -0400 Subject: [PATCH 01/17] Post-release version bump --- netbox/netbox/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index b4e1f1667..3f7606c22 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -12,7 +12,7 @@ except ImportError: "the documentation.") -VERSION = '1.2.2' +VERSION = '1.2.3-dev' # Import local configuration for setting in ['ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY']: From 5b981e2a57802ef234c2800590027c2b50b530a5 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 14 Jul 2016 16:13:02 -0400 Subject: [PATCH 02/17] Fixes #43: Introduce toggle to enforce unique IP space per VRF --- docs/configuration/optional-settings.md | 10 +++++++++- netbox/ipam/api/serializers.py | 2 +- netbox/ipam/forms.py | 4 ++-- .../migrations/0002_vrf_add_enforce_unique.py | 20 +++++++++++++++++++ netbox/ipam/models.py | 18 +++++++++++++++++ netbox/netbox/configuration.example.py | 4 ++++ netbox/netbox/settings.py | 1 + netbox/templates/ipam/vrf.html | 10 ++++++++++ netbox/templates/ipam/vrf_import.html | 7 ++++++- 9 files changed, 71 insertions(+), 5 deletions(-) create mode 100644 netbox/ipam/migrations/0002_vrf_add_enforce_unique.py diff --git a/docs/configuration/optional-settings.md b/docs/configuration/optional-settings.md index a9f42394d..fc8c13ea0 100644 --- a/docs/configuration/optional-settings.md +++ b/docs/configuration/optional-settings.md @@ -47,9 +47,17 @@ In order to send email, NetBox needs an email server configured. The following i --- +# ENFORCE_GLOBAL_UNIQUE + +Default: False + +Enforcement of unique IP space can be toggled on a per-VRF basis. To enforce unique IP space within the global table (all prefixes and IP addresses not assigned to a VRF), set `ENFORCE_GLOBAL_UNIQUE` to True. + +--- + ## LOGIN_REQUIRED -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. diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index e632111ab..144ea5482 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -12,7 +12,7 @@ class VRFSerializer(serializers.ModelSerializer): class Meta: model = VRF - fields = ['id', 'name', 'rd', 'description'] + fields = ['id', 'name', 'rd', 'enforce_unique', 'description'] class VRFNestedSerializer(VRFSerializer): diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index eb7d39e1d..607ba1254 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -25,7 +25,7 @@ class VRFForm(forms.ModelForm, BootstrapMixin): class Meta: model = VRF - fields = ['name', 'rd', 'description'] + fields = ['name', 'rd', 'enforce_unique', 'description'] labels = { 'rd': "RD", } @@ -38,7 +38,7 @@ class VRFFromCSVForm(forms.ModelForm): class Meta: model = VRF - fields = ['name', 'rd', 'description'] + fields = ['name', 'rd', 'enforce_unique', 'description'] class VRFImportForm(BulkImportForm, BootstrapMixin): diff --git a/netbox/ipam/migrations/0002_vrf_add_enforce_unique.py b/netbox/ipam/migrations/0002_vrf_add_enforce_unique.py new file mode 100644 index 000000000..373e93d80 --- /dev/null +++ b/netbox/ipam/migrations/0002_vrf_add_enforce_unique.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.7 on 2016-07-14 19:34 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('ipam', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='vrf', + name='enforce_unique', + field=models.BooleanField(default=True, help_text=b'Prevent duplicate prefixes/IP addresses within this VRF', verbose_name=b'Enforce unique space'), + ), + ] diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index e37e21bb3..447557b4e 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -1,5 +1,6 @@ from netaddr import IPNetwork, cidr_merge +from django.conf import settings from django.core.exceptions import ValidationError from django.core.urlresolvers import reverse from django.core.validators import MaxValueValidator, MinValueValidator @@ -45,6 +46,8 @@ class VRF(CreatedUpdatedModel): """ name = models.CharField(max_length=50) 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) class Meta: @@ -309,6 +312,21 @@ class IPAddress(CreatedUpdatedModel): def get_absolute_url(self): 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): if self.address: # Infer address family from IPAddress object diff --git a/netbox/netbox/configuration.example.py b/netbox/netbox/configuration.example.py index 745bdfaaf..603327c6e 100644 --- a/netbox/netbox/configuration.example.py +++ b/netbox/netbox/configuration.example.py @@ -82,3 +82,7 @@ BANNER_BOTTOM = '' # When determining the primary IP address for a device, IPv6 is preferred over IPv4 by default. Set this to True to # prefer IPv4 instead. 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 diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 3f7606c22..d47237094 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -41,6 +41,7 @@ SHORT_DATETIME_FORMAT = getattr(configuration, 'SHORT_DATETIME_FORMAT', 'Y-m-d H BANNER_TOP = getattr(configuration, 'BANNER_TOP', False) BANNER_BOTTOM = getattr(configuration, 'BANNER_BOTTOM', False) PREFER_IPV4 = getattr(configuration, 'PREFER_IPV4', False) +ENFORCE_GLOBAL_UNIQUE = getattr(configuration, 'ENFORCE_GLOBAL_UNIQUE', False) CSRF_TRUSTED_ORIGINS = ALLOWED_HOSTS # Attempt to import LDAP configuration if it has been defined diff --git a/netbox/templates/ipam/vrf.html b/netbox/templates/ipam/vrf.html index e0af3038a..e3ce30c3b 100644 --- a/netbox/templates/ipam/vrf.html +++ b/netbox/templates/ipam/vrf.html @@ -30,6 +30,16 @@ Route Distinguisher {{ vrf.rd }} + + Enforce Uniqueness + + {% if vrf.enforce_unique %} + + {% else %} + + {% endif %} + + Description diff --git a/netbox/templates/ipam/vrf_import.html b/netbox/templates/ipam/vrf_import.html index b852be6de..ce16181c4 100644 --- a/netbox/templates/ipam/vrf_import.html +++ b/netbox/templates/ipam/vrf_import.html @@ -38,6 +38,11 @@ Route distinguisher 65000:123456 + + Enforce uniqueness + Prevent duplicate prefixes/IP addresses + True + Description Short description (optional) @@ -46,7 +51,7 @@

Example

-
Customer_ABC,65000:123456,Native VRF for customer ABC
+
Customer_ABC,65000:123456,True,Native VRF for customer ABC
{% endblock %} From 8fd684a3ed20165953ce3f36f0a8c63f4c5285c8 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 14 Jul 2016 17:35:52 -0400 Subject: [PATCH 03/17] Fixes #227: Introduces support for bulk import of child devices --- netbox/dcim/forms.py | 80 ++++++++++++++----- netbox/dcim/urls.py | 1 + netbox/dcim/views.py | 17 ++++ netbox/templates/dcim/device_import.html | 2 +- .../templates/dcim/device_import_child.html | 75 +++++++++++++++++ .../dcim/inc/_device_import_header.html | 5 ++ 6 files changed, 161 insertions(+), 19 deletions(-) create mode 100644 netbox/templates/dcim/device_import_child.html create mode 100644 netbox/templates/dcim/inc/_device_import_header.html diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index e3db78ec4..4954099dd 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -426,7 +426,7 @@ class DeviceForm(forms.ModelForm, BootstrapMixin): 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', error_messages={'invalid_choice': 'Invalid device role.'}) manufacturer = forms.ModelChoiceField(queryset=Manufacturer.objects.all(), to_field_name='name', @@ -434,23 +434,15 @@ class DeviceFromCSVForm(forms.ModelForm): model_name = forms.CharField() platform = forms.ModelChoiceField(queryset=Platform.objects.all(), required=False, to_field_name='name', 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: + fields = [] model = Device - fields = ['name', 'device_role', 'manufacturer', 'model_name', 'platform', 'serial', 'site', 'rack_name', - 'position', 'face'] def clean(self): manufacturer = self.cleaned_data.get('manufacturer') 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 if manufacturer and model_name: @@ -459,6 +451,25 @@ class DeviceFromCSVForm(forms.ModelForm): except DeviceType.DoesNotExist: 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 if site and rack_name: try: @@ -468,21 +479,54 @@ class DeviceFromCSVForm(forms.ModelForm): def clean_face(self): 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: - return { - 'front': 0, - 'rear': 1, - }[face.lower()] - except KeyError: - raise forms.ValidationError('Invalid rack face ({}); must be "front" or "rear".'.format(face)) - return face + device_bay = DeviceBay.objects.get(device=parent, name=device_bay_name) + if device_bay.installed_device: + self.add_error('device_bay_name', + "Device bay ({} {}) is already occupied".format(parent, device_bay_name)) + else: + self.instance.parent_bay = device_bay + except DeviceBay.DoesNotExist: + self.add_error('device_bay_name', "Parent device/bay ({} {}) not found".format(parent, device_bay_name)) class DeviceImportForm(BulkImportForm, BootstrapMixin): csv = CSVDataField(csv_form=DeviceFromCSVForm) +class ChildDeviceImportForm(BulkImportForm, BootstrapMixin): + csv = CSVDataField(csv_form=ChildDeviceFromCSVForm) + + class DeviceBulkEditForm(forms.Form, BootstrapMixin): pk = forms.ModelMultipleChoiceField(queryset=Device.objects.all(), widget=forms.MultipleHiddenInput) device_type = forms.ModelChoiceField(queryset=DeviceType.objects.all(), required=False, label='Type') diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index 1c76e63d2..52bfcdfdb 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -92,6 +92,7 @@ urlpatterns = [ url(r'^devices/$', views.DeviceListView.as_view(), name='device_list'), 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/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/delete/$', views.DeviceBulkDeleteView.as_view(), name='device_bulk_delete'), url(r'^devices/(?P\d+)/$', views.device, name='device'), diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index c97e2a3e1..2fdbbd0a7 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -609,6 +609,23 @@ class DeviceBulkImportView(PermissionRequiredMixin, BulkImportView): 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): permission_required = 'dcim.change_device' cls = Device diff --git a/netbox/templates/dcim/device_import.html b/netbox/templates/dcim/device_import.html index bcc2d0710..f0d1cca6d 100644 --- a/netbox/templates/dcim/device_import.html +++ b/netbox/templates/dcim/device_import.html @@ -5,7 +5,7 @@ {% block title %}Device Import{% endblock %} {% block content %} -

Device Import

+{% include 'dcim/inc/_device_import_header.html' %}
diff --git a/netbox/templates/dcim/device_import_child.html b/netbox/templates/dcim/device_import_child.html new file mode 100644 index 000000000..5b9a14541 --- /dev/null +++ b/netbox/templates/dcim/device_import_child.html @@ -0,0 +1,75 @@ +{% extends '_base.html' %} +{% load render_table from django_tables2 %} +{% load form_helpers %} + +{% block title %}Device Import{% endblock %} + +{% block content %} +{% include 'dcim/inc/_device_import_header.html' with active_tab='child_import' %} +
+
+ + {% csrf_token %} + {% render_form form %} +
+ + Cancel +
+ +

CSV Format

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldDescriptionExample
NameDevice name (optional)Blade12
Device roleFunctional role of deviceBlade Server
Device manufacturerHardware manufacturerDell
Device modelHardware modelBS2000T
PlatformSoftware running on device (optional)Linux
SerialSerial number (optional)CAB00577291
Parent deviceParent deviceServer101
Device bayDevice bay nameSlot 4
+

Example

+
Blade12,Blade Server,Dell,BS2000T,Linux,CAB00577291,Server101,Slot4
+
+
+{% endblock %} diff --git a/netbox/templates/dcim/inc/_device_import_header.html b/netbox/templates/dcim/inc/_device_import_header.html new file mode 100644 index 000000000..57dd1b46e --- /dev/null +++ b/netbox/templates/dcim/inc/_device_import_header.html @@ -0,0 +1,5 @@ +

Device Import

+ From fc02ef258dd458f3dd7357d4dcddda5f35bbbc3d Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 14 Jul 2016 17:41:16 -0400 Subject: [PATCH 04/17] Fixes #301: Prevent deletion of DeviceBay when installed device is deleted --- ...010_devicebay_installed_device_set_null.py | 21 +++++++++++++++++++ netbox/dcim/models.py | 3 ++- 2 files changed, 23 insertions(+), 1 deletion(-) create mode 100644 netbox/dcim/migrations/0010_devicebay_installed_device_set_null.py diff --git a/netbox/dcim/migrations/0010_devicebay_installed_device_set_null.py b/netbox/dcim/migrations/0010_devicebay_installed_device_set_null.py new file mode 100644 index 000000000..bf2f31c57 --- /dev/null +++ b/netbox/dcim/migrations/0010_devicebay_installed_device_set_null.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.7 on 2016-07-14 21:38 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0009_site_32bit_asn_support'), + ] + + operations = [ + migrations.AlterField( + model_name='devicebay', + name='installed_device', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='parent_bay', to='dcim.Device'), + ), + ] diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 8bf97224c..ae0817eda 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -953,7 +953,8 @@ class DeviceBay(models.Model): """ device = models.ForeignKey('Device', related_name='device_bays', on_delete=models.CASCADE) 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: ordering = ['device', 'name'] From 6113c7bef61b7ae896f8c81af6bea97bfedbd4c5 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 15 Jul 2016 09:45:45 -0400 Subject: [PATCH 05/17] Fixes #307: Validate device type assignment during import validation --- netbox/dcim/models.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index ae0817eda..9224f3436 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -624,6 +624,10 @@ class Device(CreatedUpdatedModel): 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 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.") @@ -633,10 +637,7 @@ class Device(CreatedUpdatedModel): raise ValidationError("Must specify rack face with rack position.") # Validate rack space - try: - rack_face = self.face if not self.device_type.is_full_depth else None - except DeviceType.DoesNotExist: - raise ValidationError("Must specify device type.") + rack_face = self.face if not self.device_type.is_full_depth else None exclude_list = [self.pk] if self.pk else [] try: available_units = self.rack.get_available_units(u_height=self.device_type.u_height, rack_face=rack_face, From 4001cb0d5c79e720c86418b74d96e0caf0fa5293 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 15 Jul 2016 11:04:03 -0400 Subject: [PATCH 06/17] Added a custom 500 handler to include exception details --- netbox/netbox/urls.py | 4 +++- netbox/netbox/views.py | 15 ++++++++++----- netbox/templates/500.html | 10 ++++++++-- 3 files changed, 21 insertions(+), 8 deletions(-) diff --git a/netbox/netbox/urls.py b/netbox/netbox/urls.py index a7f908544..3a9d6b00a 100644 --- a/netbox/netbox/urls.py +++ b/netbox/netbox/urls.py @@ -2,10 +2,12 @@ from django.conf.urls import include, url from django.contrib import admin 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 +handler500 = handle_500 + urlpatterns = [ # Default page diff --git a/netbox/netbox/views.py b/netbox/netbox/views.py index 38988f6c7..9fda7f92c 100644 --- a/netbox/netbox/views.py +++ b/netbox/netbox/views.py @@ -1,9 +1,6 @@ -from markdown import markdown +import sys -from django.conf import settings -from django.http import Http404 from django.shortcuts import render -from django.utils.safestring import mark_safe from circuits.models import Provider, Circuit from dcim.models import Site, Rack, Device, ConsolePort, PowerPort, InterfaceConnection @@ -47,6 +44,14 @@ def home(request): def trigger_500(request): """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 " "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) diff --git a/netbox/templates/500.html b/netbox/templates/500.html index 4f65eae90..33fee68db 100644 --- a/netbox/templates/500.html +++ b/netbox/templates/500.html @@ -12,13 +12,19 @@
- Server Error + + + Server Error +

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.

If you are responsible for this installation, please consider - filing a bug report.

+ filing a bug report. Additional + information is provided below:

+
{{ exception }}
+{{ error }}
From d8e40698765c675689b933bcebd7954be66cd70b Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 15 Jul 2016 13:26:54 -0400 Subject: [PATCH 07/17] Closes #111: Implement VLAN groups --- netbox/ipam/admin.py | 10 ++- netbox/ipam/api/serializers.py | 23 ++++++- netbox/ipam/api/urls.py | 4 ++ netbox/ipam/api/views.py | 63 ++++++++++++++++--- netbox/ipam/filters.py | 31 ++++++++- netbox/ipam/forms.py | 56 ++++++++++++++++- .../migrations/0003_ipam_add_vlangroups.py | 38 +++++++++++ .../0004_ipam_vlangroup_uniqueness.py | 27 ++++++++ netbox/ipam/models.py | 44 ++++++++++++- netbox/ipam/tables.py | 28 ++++++++- netbox/ipam/urls.py | 6 ++ netbox/ipam/views.py | 29 ++++++++- netbox/templates/_base.html | 23 ++++--- netbox/templates/ipam/vlangroup_list.html | 24 +++++++ 14 files changed, 377 insertions(+), 29 deletions(-) create mode 100644 netbox/ipam/migrations/0003_ipam_add_vlangroups.py create mode 100644 netbox/ipam/migrations/0004_ipam_vlangroup_uniqueness.py create mode 100644 netbox/templates/ipam/vlangroup_list.html 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 %} - -
{% endblock %} From e7eef87f2b67c1231a2255cbea7d45227b326861 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 15 Jul 2016 15:14:49 -0400 Subject: [PATCH 09/17] Fixes #311: Correct IPAddress family evaluation on import --- netbox/ipam/forms.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 483462485..4e6e1ff0c 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -398,9 +398,9 @@ class IPAddressFromCSVForm(forms.ModelForm): name=self.cleaned_data['interface_name']) # Set as primary for device 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'] - elif self.instance.family == 6: + elif self.instance.address.version == 6: self.instance.primary_ip6_for = self.cleaned_data['device'] return super(IPAddressFromCSVForm, self).save(commit=commit) From b5393ea3b32fbad8f0ce5da0c9f47b2e9e12ec2a Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 15 Jul 2016 15:34:28 -0400 Subject: [PATCH 10/17] Corrected RackGroupNestedSerializer() definition --- netbox/dcim/api/serializers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index a17301823..5c7f655de 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -38,7 +38,7 @@ class RackGroupSerializer(serializers.ModelSerializer): fields = ['id', 'name', 'slug', 'site'] -class RackGroupNestedSerializer(SiteSerializer): +class RackGroupNestedSerializer(RackGroupSerializer): class Meta(SiteSerializer.Meta): fields = ['id', 'name', 'slug'] From ce389551c934128d792bc773e30af4673a19244b Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 15 Jul 2016 16:05:21 -0400 Subject: [PATCH 11/17] Fixes #308: Update rack assignment for all child devices when moving a parent device --- netbox/dcim/models.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 9224f3436..15df8f85e 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -681,6 +681,9 @@ class Device(CreatedUpdatedModel): 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): return ','.join([ self.name or '', From 9ca73180d241d6ad554d0c7e3edbca0d5aa0f7dd Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 15 Jul 2016 16:32:00 -0400 Subject: [PATCH 12/17] Added group to VLAN view --- netbox/templates/ipam/vlan.html | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/netbox/templates/ipam/vlan.html b/netbox/templates/ipam/vlan.html index 3412d5254..1fc0d2287 100644 --- a/netbox/templates/ipam/vlan.html +++ b/netbox/templates/ipam/vlan.html @@ -51,6 +51,16 @@ Site {{ vlan.site }} + + Group + + {% if vlan.group %} + {{ vlan.group.name }} + {% else %} + None + {% endif %} + + VLAN ID {{ vlan.vid }} From 22819177548e6a6a6c649610693d9811a2c1f1ff Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 18 Jul 2016 10:06:43 -0400 Subject: [PATCH 13/17] Fixes #320: Disallow prefixes with host masks --- netbox/ipam/models.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index bfac967fc..c02c12828 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -247,6 +247,15 @@ class Prefix(CreatedUpdatedModel): def get_absolute_url(self): 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): if self.prefix: # Clear host bits from prefix From a71709d7b7f877111af260288801069caafbd768 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 18 Jul 2016 11:35:40 -0400 Subject: [PATCH 14/17] Fixes #322: Corrected 'vlan_group' to 'group' --- netbox/ipam/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index c02c12828..e9e6a8011 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -428,7 +428,7 @@ class VLAN(CreatedUpdatedModel): def clean(self): # Validate VLAN group - if self.vlan_group and self.vlan_group.site != self.site: + 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): From 068dcf7c3597183b53f068f5cd3404aa83871647 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 18 Jul 2016 11:59:55 -0400 Subject: [PATCH 15/17] Added support for group assignment during VLAN import --- netbox/ipam/forms.py | 4 +++- netbox/templates/ipam/vlan_import.html | 7 ++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 4e6e1ff0c..2c1b24192 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -503,13 +503,15 @@ class VLANForm(forms.ModelForm, BootstrapMixin): class VLANFromCSVForm(forms.ModelForm): site = forms.ModelChoiceField(queryset=Site.objects.all(), to_field_name='name', 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]) role = forms.ModelChoiceField(queryset=Role.objects.all(), required=False, to_field_name='name', error_messages={'invalid_choice': 'Invalid role.'}) class Meta: model = VLAN - fields = ['site', 'vid', 'name', 'status_name', 'role'] + fields = ['site', 'group', 'vid', 'name', 'status_name', 'role'] def save(self, *args, **kwargs): m = super(VLANFromCSVForm, self).save(commit=False) diff --git a/netbox/templates/ipam/vlan_import.html b/netbox/templates/ipam/vlan_import.html index 538eed44a..ba1265c3f 100644 --- a/netbox/templates/ipam/vlan_import.html +++ b/netbox/templates/ipam/vlan_import.html @@ -33,6 +33,11 @@ Name of assigned site LAS2 + + Group + Name of VLAN group (optional) + Backend Network + ID Configured VLAN ID @@ -56,7 +61,7 @@

Example

-
LAS2,1400,Cameras,Active,Security
+
LAS2,Backend Network,1400,Cameras,Active,Security
{% endblock %} From 7aa059ddcfb5b02e96e37b930848b0a0a5adfa6b Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 18 Jul 2016 13:03:40 -0400 Subject: [PATCH 16/17] Fixes #317: Rack elevation display fix for device types greater than 42U in height --- netbox/project-static/css/base.css | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/netbox/project-static/css/base.css b/netbox/project-static/css/base.css index 9f4bb5e24..13f4e1455 100644 --- a/netbox/project-static/css/base.css +++ b/netbox/project-static/css/base.css @@ -225,6 +225,22 @@ ul.rack li.h41u { height: 820px; } ul.rack li.h41u a, ul.rack li.h41u span { padding: 400px 0; } ul.rack li.h42u { height: 840px; } 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 { color: #ffffff; display: block; From 28a87e335268a71d5ee9569b7721207b1ec2b991 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 18 Jul 2016 13:43:39 -0400 Subject: [PATCH 17/17] Version bump: v1.3.0 --- netbox/netbox/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index d47237094..69d40a68b 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -12,7 +12,7 @@ except ImportError: "the documentation.") -VERSION = '1.2.3-dev' +VERSION = '1.3.0' # Import local configuration for setting in ['ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY']: