From bf44e512ff3357b0c156092499d0fa12dc46900b 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 4a00971d440c2516af3eb22ccab54e2871c074ea 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 dd62caf2f0cda5cb4a049604adbd9c71aae99f20 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 f301af5ecd06216a58734e75f679ba8f73a017b9 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 5def0e91d7b1e20392d7a06a2261ddfab13fd1b3 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 23451fe974250e4d75d081bbf41968e9b88e8cb5 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 45a8ee7325d19af7594a180299bf4f93510dbf11 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 4f95ce498405def8143a49eb87f65c705b99211a 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 518af1b95c8b791aa025015dcfecebf220fe3b33 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 534e6ac19edc5e8f54bcc7f8fdc2ff96bc728d46 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 46da9866e37867b5506c6b8450e938976c378e1f 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 32d8cf451a7f29e7aa6c4e0512dab01723ae7fe4 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 d201dad53546aa28a29eb24606201ebe7c08b2e9 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 cb4643d810db504815bf829c1e2d0f473f82d3a3 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 d6c2fe23858cb13ba7b557253bb19f78b246d645 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 4f347d342890d5fa4cbbee2028b6df6d3ae590e4 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']: