From c91f41e9841fc8a331194c8213a4bb392ef14b19 Mon Sep 17 00:00:00 2001 From: Matthew Yauch Date: Thu, 26 Jan 2017 15:28:11 -0800 Subject: [PATCH 01/26] Update upgrading.md Added directive to copy the LDAP configuration if in use. I upgraded a whole two versions before realizing my LDAP was broken. --- docs/installation/upgrading.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/installation/upgrading.md b/docs/installation/upgrading.md index afb36f464..193d7e74a 100644 --- a/docs/installation/upgrading.md +++ b/docs/installation/upgrading.md @@ -27,6 +27,12 @@ If you followed the original installation guide to set up gunicorn, be sure to c # cp /opt/netbox-X.Y.Z/gunicorn_config.py /opt/netbox/gunicorn_config.py ``` +Copy the LDAP configuration if using LDAP: + +```no-highlight +# cp /opt/netbox-X.Y.Z/netbox/netbox/ldap_config.py /opt/netbox/netbox/netbox/ldap_config.py +``` + ## Option B: Clone the Git Repository (latest master release) This guide assumes that NetBox is installed at `/opt/netbox`. Pull down the most recent iteration of the master branch: From 1e1dd8c6685a0f71c69c7770a83571b74f8469cb Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 3 Feb 2017 13:59:26 -0500 Subject: [PATCH 02/26] 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 7eb8e04a2..48786a3f8 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -12,7 +12,7 @@ except ImportError: "the documentation.") -VERSION = '1.8.4' +VERSION = '1.8.5-dev' # Import local configuration for setting in ['ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY']: From dc606645fd633c220a69197ea22582b3c7235c93 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 9 Feb 2017 23:27:12 -0500 Subject: [PATCH 03/26] Fixes #884: Preserve selected rack unit when changing a device's rack face --- netbox/project-static/js/forms.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/netbox/project-static/js/forms.js b/netbox/project-static/js/forms.js index 3a5ad2b83..5a736627e 100644 --- a/netbox/project-static/js/forms.js +++ b/netbox/project-static/js/forms.js @@ -73,6 +73,7 @@ $(document).ready(function() { // Resolve child field by ID specified in parent var child_name = $(this).attr('filter-for'); var child_field = $('#id_' + child_name); + var child_selected = child_field.val(); // Wipe out any existing options within the child field child_field.empty(); @@ -106,7 +107,9 @@ $(document).ready(function() { $.each(response, function (index, choice) { var option = $("").attr("value", choice.id).text(choice[display_field]); if (disabled_indicator && choice[disabled_indicator] && choice.id != initial_value) { - option.attr("disabled", "disabled") + option.attr("disabled", "disabled"); + } else if (choice.id == child_selected) { + option.attr("selected", "selected"); } child_field.append(option); }); From b69564f5c9fb100fd2ccfdedde326d0270d765f7 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 15 Feb 2017 15:03:31 -0500 Subject: [PATCH 04/26] Exposed the request to ObjectEditView's alter_obj() --- netbox/circuits/views.py | 6 +++--- netbox/dcim/views.py | 6 +++--- netbox/ipam/views.py | 6 +++--- netbox/utilities/views.py | 8 ++++---- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index c85fad1a1..de5ef1a22 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -224,9 +224,9 @@ class CircuitTerminationEditView(PermissionRequiredMixin, ObjectEditView): fields_initial = ['term_side'] template_name = 'circuits/circuittermination_edit.html' - def alter_obj(self, obj, args, kwargs): - if 'circuit' in kwargs: - obj.circuit = get_object_or_404(Circuit, pk=kwargs['circuit']) + def alter_obj(self, obj, request, url_args, url_kwargs): + if 'circuit' in url_kwargs: + obj.circuit = get_object_or_404(Circuit, pk=url_kwargs['circuit']) return obj def get_return_url(self, obj): diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 17f74eae3..26226b79f 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1517,9 +1517,9 @@ class ModuleEditView(PermissionRequiredMixin, ComponentEditView): model = Module form_class = forms.ModuleForm - def alter_obj(self, obj, args, kwargs): - if 'device' in kwargs: - obj.device = get_object_or_404(Device, pk=kwargs['device']) + def alter_obj(self, obj, request, url_args, url_kwargs): + if 'device' in url_kwargs: + obj.device = get_object_or_404(Device, pk=url_kwargs['device']) return obj diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index f93f297e0..b53bb82ab 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -764,9 +764,9 @@ class ServiceEditView(PermissionRequiredMixin, ObjectEditView): form_class = forms.ServiceForm template_name = 'ipam/service_edit.html' - def alter_obj(self, obj, args, kwargs): - if 'device' in kwargs: - obj.device = get_object_or_404(Device, pk=kwargs['device']) + def alter_obj(self, obj, request, url_args, url_kwargs): + if 'device' in url_kwargs: + obj.device = get_object_or_404(Device, pk=url_kwargs['device']) return obj def get_return_url(self, obj): diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index 315298383..9d1561a48 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -145,9 +145,9 @@ class ObjectEditView(View): return get_object_or_404(self.model, pk=kwargs['pk']) return self.model() - def alter_obj(self, obj, args, kwargs): + def alter_obj(self, obj, request, url_args, url_kwargs): # Allow views to add extra info to an object before it is processed. For example, a parent object can be defined - # given some parameter from the request URI. + # given some parameter from the request URL. return obj def get_return_url(self, obj): @@ -159,7 +159,7 @@ class ObjectEditView(View): def get(self, request, *args, **kwargs): obj = self.get_object(kwargs) - obj = self.alter_obj(obj, args, kwargs) + obj = self.alter_obj(obj, request, args, kwargs) initial_data = {k: request.GET[k] for k in self.fields_initial if k in request.GET} form = self.form_class(instance=obj, initial=initial_data) @@ -173,7 +173,7 @@ class ObjectEditView(View): def post(self, request, *args, **kwargs): obj = self.get_object(kwargs) - obj = self.alter_obj(obj, args, kwargs) + obj = self.alter_obj(obj, request, args, kwargs) form = self.form_class(request.POST, instance=obj) if form.is_valid(): From 181539651ff36317ab48ea4aa3e07d78d1a7d928 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 16 Feb 2017 13:46:58 -0500 Subject: [PATCH 05/26] Rack reservations (#900) * Initial work on rack reservations * Added views for rack reservations * Implemented ArrayFieldSelectMultiple form widget * Implemented API endpoints for rack reservations * Tweaked the database migration --- netbox/dcim/admin.py | 7 ++- netbox/dcim/api/serializers.py | 25 ++++++++-- netbox/dcim/api/urls.py | 4 ++ netbox/dcim/api/views.py | 24 +++++++++- netbox/dcim/filters.py | 14 +++++- netbox/dcim/forms.py | 37 +++++++++++++-- .../migrations/0026_add_rack_reservations.py | 33 +++++++++++++ netbox/dcim/models.py | 46 +++++++++++++++++++ netbox/dcim/tests/test_apis.py | 1 + netbox/dcim/urls.py | 5 ++ netbox/dcim/views.py | 37 ++++++++++++++- netbox/project-static/css/base.css | 9 ++++ netbox/templates/dcim/inc/rack_elevation.html | 11 ++++- netbox/templates/dcim/rack.html | 45 ++++++++++++++++++ netbox/utilities/forms.py | 21 +++++++++ netbox/utilities/templatetags/helpers.py | 8 ++++ 16 files changed, 314 insertions(+), 13 deletions(-) create mode 100644 netbox/dcim/migrations/0026_add_rack_reservations.py diff --git a/netbox/dcim/admin.py b/netbox/dcim/admin.py index 8828b52c4..fb4c281ac 100644 --- a/netbox/dcim/admin.py +++ b/netbox/dcim/admin.py @@ -4,7 +4,7 @@ from django.db.models import Count from .models import ( ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceTemplate, Manufacturer, Module, Platform, - PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackRole, Site, + PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, Site, ) @@ -37,6 +37,11 @@ class RackAdmin(admin.ModelAdmin): list_display = ['name', 'facility_id', 'site', 'group', 'tenant', 'role', 'type', 'width', 'u_height'] +@admin.register(RackReservation) +class RackRackReservationAdmin(admin.ModelAdmin): + list_display = ['rack', 'units', 'description', 'user', 'created'] + + # # Device types # diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index f81f299af..34e0b1a1e 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -4,8 +4,8 @@ from ipam.models import IPAddress from dcim.models import ( ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, DeviceType, DeviceRole, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, Module, Platform, PowerOutlet, - PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackRole, RACK_FACE_FRONT, RACK_FACE_REAR, Site, - SUBDEVICE_ROLE_CHILD, SUBDEVICE_ROLE_PARENT, + PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RACK_FACE_FRONT, + RACK_FACE_REAR, Site, SUBDEVICE_ROLE_CHILD, SUBDEVICE_ROLE_PARENT, ) from extras.api.serializers import CustomFieldSerializer from tenancy.api.serializers import TenantNestedSerializer @@ -70,6 +70,12 @@ class RackRoleNestedSerializer(RackRoleSerializer): # Racks # +class RackReservationNestedSerializer(serializers.ModelSerializer): + + class Meta: + model = RackReservation + fields = ['id', 'units', 'created', 'user', 'description'] + class RackSerializer(CustomFieldSerializer, serializers.ModelSerializer): site = SiteNestedSerializer() @@ -92,10 +98,11 @@ class RackNestedSerializer(RackSerializer): class RackDetailSerializer(RackSerializer): front_units = serializers.SerializerMethodField() rear_units = serializers.SerializerMethodField() + reservations = RackReservationNestedSerializer(many=True) class Meta(RackSerializer.Meta): fields = ['id', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'role', 'type', 'width', - 'u_height', 'desc_units', 'comments', 'custom_fields', 'front_units', 'rear_units'] + 'u_height', 'desc_units', 'reservations', 'comments', 'custom_fields', 'front_units', 'rear_units'] def get_front_units(self, obj): units = obj.get_rack_units(face=RACK_FACE_FRONT) @@ -110,6 +117,18 @@ class RackDetailSerializer(RackSerializer): return units +# +# Rack reservations +# + +class RackReservationSerializer(serializers.ModelSerializer): + rack = RackNestedSerializer() + + class Meta: + model = RackReservation + fields = ['id', 'rack', 'units', 'created', 'user', 'description'] + + # # Manufacturers # diff --git a/netbox/dcim/api/urls.py b/netbox/dcim/api/urls.py index 23787f4b4..432135925 100644 --- a/netbox/dcim/api/urls.py +++ b/netbox/dcim/api/urls.py @@ -27,6 +27,10 @@ urlpatterns = [ url(r'^racks/(?P\d+)/$', RackDetailView.as_view(), name='rack_detail'), url(r'^racks/(?P\d+)/rack-units/$', RackUnitListView.as_view(), name='rack_units'), + # Rack reservations + url(r'^rack-reservations/$', RackReservationListView.as_view(), name='rackreservation_list'), + url(r'^rack-reservations/(?P\d+)/$', RackReservationDetailView.as_view(), name='rackreservation_detail'), + # Manufacturers url(r'^manufacturers/$', ManufacturerListView.as_view(), name='manufacturer_list'), url(r'^manufacturers/(?P\d+)/$', ManufacturerDetailView.as_view(), name='manufacturer_detail'), diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index e76ec82ad..5679df579 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -11,7 +11,8 @@ from django.shortcuts import get_object_or_404 from dcim.models import ( ConsolePort, ConsoleServerPort, Device, DeviceBay, DeviceRole, DeviceType, IFACE_FF_VIRTUAL, Interface, - InterfaceConnection, Manufacturer, Module, Platform, PowerOutlet, PowerPort, Rack, RackGroup, RackRole, Site, + InterfaceConnection, Manufacturer, Module, Platform, PowerOutlet, PowerPort, Rack, RackGroup, RackReservation, + RackRole, Site, ) from dcim import filters from extras.api.views import CustomFieldModelAPIView @@ -134,6 +135,27 @@ class RackUnitListView(APIView): return Response(elevation) +# +# Rack reservations +# + +class RackReservationListView(generics.ListAPIView): + """ + List all rack reservation + """ + queryset = RackReservation.objects.all() + serializer_class = serializers.RackReservationSerializer + filter_class = filters.RackReservationFilter + + +class RackReservationDetailView(generics.RetrieveAPIView): + """ + Retrieve a single rack reservation + """ + queryset = RackReservation.objects.all() + serializer_class = serializers.RackReservationSerializer + + # # Manufacturers # diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index 79024b605..256dd0084 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -8,7 +8,7 @@ from tenancy.models import Tenant from utilities.filters import NullableModelMultipleChoiceFilter from .models import ( ConsolePort, ConsoleServerPort, Device, DeviceRole, DeviceType, Interface, InterfaceConnection, Manufacturer, - Platform, PowerOutlet, PowerPort, Rack, RackGroup, RackRole, Site, + Platform, PowerOutlet, PowerPort, Rack, RackGroup, RackReservation, RackRole, Site, ) @@ -122,6 +122,18 @@ class RackFilter(CustomFieldFilterSet, django_filters.FilterSet): ) +class RackReservationFilter(django_filters.FilterSet): + rack_id = django_filters.ModelMultipleChoiceFilter( + name='rack', + queryset=Rack.objects.all(), + label='Rack (ID)', + ) + + class Meta: + model = RackReservation + fields = ['rack', 'user'] + + class DeviceTypeFilter(CustomFieldFilterSet, django_filters.FilterSet): q = django_filters.MethodFilter( action='search', diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 9f6c7bde6..64e8b57fa 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -1,6 +1,7 @@ import re from django import forms +from django.contrib.postgres.forms.array import SimpleArrayField from django.core.exceptions import ValidationError from django.db.models import Count, Q @@ -8,9 +9,9 @@ from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFi from ipam.models import IPAddress from tenancy.models import Tenant from utilities.forms import ( - APISelect, add_blank_choice, BootstrapMixin, BulkEditForm, BulkImportForm, CommentField, CSVDataField, - ExpandableNameField, FilterChoiceField, FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SmallTextarea, - SlugField, + APISelect, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm, BulkImportForm, CommentField, + CSVDataField, ExpandableNameField, FilterChoiceField, FlexibleModelChoiceField, Livesearch, SelectWithDisabled, + SmallTextarea, SlugField, ) from .formfields import MACAddressFormField @@ -19,7 +20,7 @@ from .models import ( ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceRole, DeviceType, Interface, IFACE_FF_CHOICES, IFACE_FF_VIRTUAL, IFACE_ORDERING_CHOICES, InterfaceConnection, InterfaceTemplate, Manufacturer, Module, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, RACK_TYPE_CHOICES, - RACK_WIDTH_CHOICES, Rack, RackGroup, RackRole, Site, STATUS_CHOICES, SUBDEVICE_ROLE_CHILD + RACK_WIDTH_CHOICES, Rack, RackGroup, RackReservation, RackRole, Site, STATUS_CHOICES, SUBDEVICE_ROLE_CHILD ) @@ -243,6 +244,34 @@ class RackFilterForm(BootstrapMixin, CustomFieldFilterForm): null_option=(0, 'None')) +# +# Rack reservations +# + +class RackReservationForm(BootstrapMixin, forms.ModelForm): + units = SimpleArrayField(forms.IntegerField(), widget=ArrayFieldSelectMultiple(attrs={'size': 10})) + + class Meta: + model = RackReservation + fields = ['units', 'description'] + + def __init__(self, *args, **kwargs): + + super(RackReservationForm, self).__init__(*args, **kwargs) + + # Populate rack unit choices + self.fields['units'].widget.choices = self._get_unit_choices() + + def _get_unit_choices(self): + rack = self.instance.rack + reserved_units = [] + for resv in rack.reservations.exclude(pk=self.instance.pk): + for u in resv.units: + reserved_units.append(u) + unit_choices = [(u, {'label': str(u), 'disabled': u in reserved_units}) for u in rack.units] + return unit_choices + + # # Manufacturers # diff --git a/netbox/dcim/migrations/0026_add_rack_reservations.py b/netbox/dcim/migrations/0026_add_rack_reservations.py new file mode 100644 index 000000000..b9d4f8214 --- /dev/null +++ b/netbox/dcim/migrations/0026_add_rack_reservations.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.4 on 2017-02-16 18:43 +from __future__ import unicode_literals + +from django.conf import settings +import django.contrib.postgres.fields +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('dcim', '0025_devicetype_add_interface_ordering'), + ] + + operations = [ + migrations.CreateModel( + name='RackReservation', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('units', django.contrib.postgres.fields.ArrayField(base_field=models.PositiveSmallIntegerField(), size=None)), + ('created', models.DateTimeField(auto_now_add=True)), + ('description', models.CharField(max_length=100)), + ('rack', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, related_name='reservations', to='dcim.Rack')), + ('user', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['created'], + }, + ), + ] diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index d29ca745d..1225988ca 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -1,8 +1,10 @@ from collections import OrderedDict from django.conf import settings +from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.fields import GenericRelation +from django.contrib.postgres.fields import ArrayField from django.core.exceptions import ValidationError from django.core.urlresolvers import reverse from django.core.validators import MaxValueValidator, MinValueValidator @@ -478,6 +480,50 @@ class Rack(CreatedUpdatedModel, CustomFieldModel): return int(float(self.u_height - u_available) / self.u_height * 100) +@python_2_unicode_compatible +class RackReservation(models.Model): + """ + One or more reserved units within a Rack. + """ + rack = models.ForeignKey('Rack', related_name='reservations', editable=False, on_delete=models.CASCADE) + units = ArrayField(models.PositiveSmallIntegerField()) + created = models.DateTimeField(auto_now_add=True) + user = models.ForeignKey(User, editable=False, on_delete=models.PROTECT) + description = models.CharField(max_length=100) + + class Meta: + ordering = ['created'] + + def __str__(self): + return u"Reservation for rack {}".format(self.rack) + + def clean(self): + + if self.units: + + # Validate that all specified units exist in the Rack. + invalid_units = [u for u in self.units if u not in self.rack.units] + if invalid_units: + raise ValidationError({ + 'units': u"Invalid unit(s) for {}U rack: {}".format( + self.rack.u_height, + ', '.join([str(u) for u in invalid_units]), + ), + }) + + # Check that none of the units has already been reserved for this Rack. + reserved_units = [] + for resv in self.rack.reservations.exclude(pk=self.pk): + reserved_units += resv.units + conflicting_units = [u for u in self.units if u in reserved_units] + if conflicting_units: + raise ValidationError({ + 'units': 'The following units have already been reserved: {}'.format( + ', '.join([str(u) for u in conflicting_units]), + ) + }) + + # # Device Types # diff --git a/netbox/dcim/tests/test_apis.py b/netbox/dcim/tests/test_apis.py index 0f7d1bbe3..cec552984 100644 --- a/netbox/dcim/tests/test_apis.py +++ b/netbox/dcim/tests/test_apis.py @@ -151,6 +151,7 @@ class RackTest(APITestCase): 'width', 'u_height', 'desc_units', + 'reservations', 'comments', 'custom_fields', 'front_units', diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index 58bc91802..1b337ad6e 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -29,6 +29,10 @@ urlpatterns = [ url(r'^rack-roles/delete/$', views.RackRoleBulkDeleteView.as_view(), name='rackrole_bulk_delete'), url(r'^rack-roles/(?P\d+)/edit/$', views.RackRoleEditView.as_view(), name='rackrole_edit'), + # Rack reservations + url(r'^rack-reservations/(?P\d+)/edit/$', views.RackReservationEditView.as_view(), name='rackreservation_edit'), + url(r'^rack-reservations/(?P\d+)/delete/$', views.RackReservationDeleteView.as_view(), name='rackreservation_delete'), + # Racks url(r'^racks/$', views.RackListView.as_view(), name='rack_list'), url(r'^racks/add/$', views.RackEditView.as_view(), name='rack_add'), @@ -38,6 +42,7 @@ urlpatterns = [ url(r'^racks/(?P\d+)/$', views.rack, name='rack'), url(r'^racks/(?P\d+)/edit/$', views.RackEditView.as_view(), name='rack_edit'), url(r'^racks/(?P\d+)/delete/$', views.RackDeleteView.as_view(), name='rack_delete'), + url(r'^racks/(?P\d+)/reservations/add/$', views.RackReservationEditView.as_view(), name='rack_add_reservation'), # Manufacturers url(r'^manufacturers/$', views.ManufacturerListView.as_view(), name='manufacturer_list'), diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 26226b79f..4bec35be9 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -26,7 +26,7 @@ from .models import ( CONNECTION_STATUS_CONNECTED, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, Module, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, - RackRole, Site, + RackReservation, RackRole, Site, ) @@ -269,8 +269,16 @@ def rack(request, pk): next_rack = Rack.objects.filter(site=rack.site, name__gt=rack.name).order_by('name').first() prev_rack = Rack.objects.filter(site=rack.site, name__lt=rack.name).order_by('-name').first() + reservations = RackReservation.objects.filter(rack=rack) + reserved_units = {} + for r in reservations: + for u in r.units: + reserved_units[u] = r + return render(request, 'dcim/rack.html', { 'rack': rack, + 'reservations': reservations, + 'reserved_units': reserved_units, 'nonracked_devices': nonracked_devices, 'next_rack': next_rack, 'prev_rack': prev_rack, @@ -317,6 +325,33 @@ class RackBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): default_return_url = 'dcim:rack_list' +# +# Rack reservations +# + +class RackReservationEditView(PermissionRequiredMixin, ObjectEditView): + permission_required = 'dcim.change_rackreservation' + model = RackReservation + form_class = forms.RackReservationForm + + def alter_obj(self, obj, request, args, kwargs): + if not obj.pk: + obj.rack = get_object_or_404(Rack, pk=kwargs['rack']) + obj.user = request.user + return obj + + def get_return_url(self, obj): + return obj.rack.get_absolute_url() + + +class RackReservationDeleteView(PermissionRequiredMixin, ObjectDeleteView): + permission_required = 'dcim.delete_rackreservation' + model = RackReservation + + def get_return_url(self, obj): + return obj.rack.get_absolute_url() + + # # Manufacturers # diff --git a/netbox/project-static/css/base.css b/netbox/project-static/css/base.css index 4f569edea..11ea04b72 100644 --- a/netbox/project-static/css/base.css +++ b/netbox/project-static/css/base.css @@ -264,6 +264,15 @@ ul.rack_far_face li.blocked { #ffc7c7 14px ); } +ul.rack_near_face li.reserved { + background: repeating-linear-gradient( + 45deg, + #f7f7f7, + #f7f7f7 7px, + #c7c7ff 7px, + #c7c7ff 14px + ); +} ul.rack_near_face { z-index: 200; } diff --git a/netbox/templates/dcim/inc/rack_elevation.html b/netbox/templates/dcim/inc/rack_elevation.html index 0ffc6b7ad..049dcbc61 100644 --- a/netbox/templates/dcim/inc/rack_elevation.html +++ b/netbox/templates/dcim/inc/rack_elevation.html @@ -1,3 +1,5 @@ +{% load helpers %} +
    {% for u in rack.units %}
  • {{ u }}
  • @@ -35,9 +37,14 @@ {% endifequal %} {% else %} -
  • +
  • {% if perms.dcim.add_device %} - add device + add device {% endif %}
  • {% endif %} diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index d73a0b560..37cddf213 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -189,6 +189,51 @@ {% endif %} +
    +
    + Reservations +
    + {% if reservations %} + + + + + + + {% for resv in reservations %} + + + + + + {% endfor %} +
    UnitsDescription
    {{ resv.units|join:', ' }} + {{ resv.description }}
    + {{ resv.user }} · {{ resv.created }} +
    + {% if perms.change_rackreservation %} + + + + {% endif %} + {% if perms.delete_rackreservation %} + + + + {% endif %} +
    + {% else %} +
    None
    + {% endif %} + {% if perms.dcim.add_rackreservation %} + + {% endif %} +
    diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index f6e0b36b1..6eb11c208 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -169,6 +169,27 @@ class SelectWithDisabled(forms.Select): force_text(option_label)) +class ArrayFieldSelectMultiple(SelectWithDisabled, forms.SelectMultiple): + """ + MultiSelect widgets for a SimpleArrayField. Choices must be populated on the widget. + """ + + def __init__(self, *args, **kwargs): + self.delimiter = kwargs.pop('delimiter', ',') + super(ArrayFieldSelectMultiple, self).__init__(*args, **kwargs) + + def render_options(self, selected_choices): + # Split the delimited string of values into a list + if selected_choices: + selected_choices = selected_choices.split(self.delimiter) + return super(ArrayFieldSelectMultiple, self).render_options(selected_choices) + + def value_from_datadict(self, data, files, name): + # Condense the list of selected choices into a delimited string + data = super(ArrayFieldSelectMultiple, self).value_from_datadict(data, files, name) + return self.delimiter.join(data) + + class APISelect(SelectWithDisabled): """ A select widget populated via an API call diff --git a/netbox/utilities/templatetags/helpers.py b/netbox/utilities/templatetags/helpers.py index 74d490390..164aa24b2 100644 --- a/netbox/utilities/templatetags/helpers.py +++ b/netbox/utilities/templatetags/helpers.py @@ -27,6 +27,14 @@ def getlist(value, arg): return value.getlist(arg) +@register.filter +def getkey(value, key): + """ + Return a dictionary item specified by key + """ + return value[key] + + @register.filter(is_safe=True) def gfm(value): """ From e8896fe238b098d330916610bd0af789c922dfce Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 16 Feb 2017 15:13:35 -0500 Subject: [PATCH 06/26] Closes #898: Expand circuits list in provider view --- netbox/circuits/views.py | 3 +- netbox/templates/circuits/provider.html | 46 +++++++++++++++++++++++-- 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index de5ef1a22..466104883 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -31,7 +31,8 @@ class ProviderListView(ObjectListView): def provider(request, slug): provider = get_object_or_404(Provider, slug=slug) - circuits = Circuit.objects.filter(provider=provider) + circuits = Circuit.objects.filter(provider=provider).select_related('type', 'tenant')\ + .prefetch_related('terminations__site') show_graphs = Graph.objects.filter(type=GRAPH_TYPE_PROVIDER).exists() return render(request, 'circuits/provider.html', { diff --git a/netbox/templates/circuits/provider.html b/netbox/templates/circuits/provider.html index c9c8b9742..5465b8599 100644 --- a/netbox/templates/circuits/provider.html +++ b/netbox/templates/circuits/provider.html @@ -48,7 +48,7 @@

    {{ provider }}

    {% include 'inc/created_updated.html' with obj=provider %}
    -
    +
    Provider @@ -104,6 +104,12 @@ {% endif %} + + Circuits + + {{ provider.circuits.count }} + +
    {% with provider.get_custom_fields as custom_fields %} @@ -122,12 +128,20 @@
    -
    +
    Circuits
    + + + + + + + + {% for c in circuits %} + + + + {% empty %} From 9d44d5d4e78d247d0d7e5d37e94208d8da3d6489 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 16 Feb 2017 15:17:13 -0500 Subject: [PATCH 07/26] Fixes #897: Fixed power connections CSV export --- netbox/dcim/models.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 1225988ca..5ec28231f 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -1110,8 +1110,8 @@ class PowerPort(models.Model): return self.name # Used for connections export - def csv_format(self): - return ','.join([ + def to_csv(self): + return csv_format([ self.power_outlet.device.identifier if self.power_outlet else None, self.power_outlet.name if self.power_outlet else None, self.device.identifier, From 198ed859ff3d4264e029a2420bdf1363510c4085 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 17 Feb 2017 14:48:00 -0500 Subject: [PATCH 08/26] Closes #198: Support for rackless devices (#902) * Initial work to support rackless devices * Updated device component connection forms * Updated IP address assignment form * Updated circuit termination form * Formatting cleanup * Fixed tests --- netbox/circuits/forms.py | 54 ++- netbox/dcim/api/serializers.py | 9 +- netbox/dcim/filters.py | 6 +- netbox/dcim/fixtures/dcim.json | 11 + netbox/dcim/forms.py | 368 +++++++++++++----- .../dcim/migrations/0027_device_add_site.py | 21 + .../0028_device_copy_rack_to_site.py | 23 ++ .../migrations/0029_allow_rackless_devices.py | 26 ++ netbox/dcim/models.py | 88 +++-- netbox/dcim/tables.py | 6 +- netbox/dcim/tests/test_apis.py | 4 + netbox/dcim/tests/test_models.py | 15 +- netbox/dcim/views.py | 4 +- netbox/ipam/forms.py | 64 ++- netbox/ipam/views.py | 2 + netbox/project-static/js/forms.js | 28 +- .../templates/dcim/consoleport_connect.html | 9 + .../dcim/consoleserverport_connect.html | 11 +- netbox/templates/dcim/device.html | 18 +- netbox/templates/dcim/inc/device_header.html | 22 +- .../dcim/interfaceconnection_edit.html | 140 +++---- .../templates/dcim/poweroutlet_connect.html | 11 +- netbox/templates/dcim/powerport_connect.html | 11 +- 23 files changed, 684 insertions(+), 267 deletions(-) create mode 100644 netbox/dcim/migrations/0027_device_add_site.py create mode 100644 netbox/dcim/migrations/0028_device_copy_rack_to_site.py create mode 100644 netbox/dcim/migrations/0029_allow_rackless_devices.py diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py index 4b9e949f8..7390c2216 100644 --- a/netbox/circuits/forms.py +++ b/netbox/circuits/forms.py @@ -143,19 +143,49 @@ class CircuitFilterForm(BootstrapMixin, CustomFieldFilterForm): # class CircuitTerminationForm(BootstrapMixin, forms.ModelForm): - site = forms.ModelChoiceField(queryset=Site.objects.all(), widget=forms.Select(attrs={'filter-for': 'rack'})) - rack = forms.ModelChoiceField(queryset=Rack.objects.all(), required=False, label='Rack', - widget=APISelect(api_url='/api/dcim/racks/?site_id={{site}}', - attrs={'filter-for': 'device'})) - device = forms.ModelChoiceField(queryset=Device.objects.all(), required=False, label='Device', - widget=APISelect(api_url='/api/dcim/devices/?rack_id={{rack}}', - display_field='display_name', attrs={'filter-for': 'interface'})) - livesearch = forms.CharField(required=False, label='Device', widget=Livesearch( - query_key='q', query_url='dcim-api:device_list', field_to_update='device') + site = forms.ModelChoiceField( + queryset=Site.objects.all(), + widget=forms.Select( + attrs={'filter-for': 'rack'} + ) + ) + rack = forms.ModelChoiceField( + queryset=Rack.objects.all(), + required=False, + label='Rack', + widget=APISelect( + api_url='/api/dcim/racks/?site_id={{site}}', + attrs={'filter-for': 'device', 'nullable': 'true'} + ) + ) + device = forms.ModelChoiceField( + queryset=Device.objects.all(), + required=False, + label='Device', + widget=APISelect( + api_url='/api/dcim/devices/?site_id={{site}}&rack_id={{rack}}', + display_field='display_name', + attrs={'filter-for': 'interface'} + ) + ) + livesearch = forms.CharField( + required=False, + label='Device', + widget=Livesearch( + query_key='q', + query_url='dcim-api:device_list', + field_to_update='device' + ) + ) + interface = forms.ModelChoiceField( + queryset=Interface.objects.all(), + required=False, + label='Interface', + widget=APISelect( + api_url='/api/dcim/devices/{{device}}/interfaces/?type=physical', + disabled_indicator='is_connected' + ) ) - interface = forms.ModelChoiceField(queryset=Interface.objects.all(), required=False, label='Interface', - widget=APISelect(api_url='/api/dcim/devices/{{device}}/interfaces/?type=physical', - disabled_indicator='is_connected')) class Meta: model = CircuitTermination diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 34e0b1a1e..358fcd1f2 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -275,6 +275,7 @@ class DeviceSerializer(CustomFieldSerializer, serializers.ModelSerializer): device_role = DeviceRoleNestedSerializer() tenant = TenantNestedSerializer() platform = PlatformNestedSerializer() + site = SiteNestedSerializer() rack = RackNestedSerializer() primary_ip = DeviceIPAddressNestedSerializer() primary_ip4 = DeviceIPAddressNestedSerializer() @@ -283,9 +284,11 @@ class DeviceSerializer(CustomFieldSerializer, serializers.ModelSerializer): class Meta: model = Device - fields = ['id', 'name', 'display_name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', - 'asset_tag', 'rack', 'position', 'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4', - 'primary_ip6', 'comments', 'custom_fields'] + fields = [ + 'id', 'name', 'display_name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag', + 'site', 'rack', 'position', 'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4', 'primary_ip6', + 'comments', 'custom_fields', + ] def get_parent_device(self, obj): try: diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index 256dd0084..58e339278 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -175,12 +175,12 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet): label='MAC address', ) site_id = django_filters.ModelMultipleChoiceFilter( - name='rack__site', + name='site', queryset=Site.objects.all(), label='Site (ID)', ) site = django_filters.ModelMultipleChoiceFilter( - name='rack__site__slug', + name='site__slug', queryset=Site.objects.all(), to_field_name='slug', label='Site name (slug)', @@ -190,7 +190,7 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet): queryset=RackGroup.objects.all(), label='Rack group (ID)', ) - rack_id = django_filters.ModelMultipleChoiceFilter( + rack_id = NullableModelMultipleChoiceFilter( name='rack', queryset=Rack.objects.all(), label='Rack (ID)', diff --git a/netbox/dcim/fixtures/dcim.json b/netbox/dcim/fixtures/dcim.json index 7c011eb89..4a9eb15e4 100644 --- a/netbox/dcim/fixtures/dcim.json +++ b/netbox/dcim/fixtures/dcim.json @@ -1915,6 +1915,7 @@ "platform": 1, "name": "test1-edge1", "serial": "5555555555", + "site": 1, "rack": 1, "position": 1, "face": 0, @@ -1935,6 +1936,7 @@ "platform": 1, "name": "test1-core1", "serial": "", + "site": 1, "rack": 1, "position": 17, "face": 0, @@ -1955,6 +1957,7 @@ "platform": 1, "name": "test1-spine1", "serial": "", + "site": 1, "rack": 1, "position": 33, "face": 0, @@ -1975,6 +1978,7 @@ "platform": 1, "name": "test1-leaf1", "serial": "", + "site": 1, "rack": 1, "position": 34, "face": 0, @@ -1995,6 +1999,7 @@ "platform": 1, "name": "test1-leaf2", "serial": "9823478293748", + "site": 1, "rack": 2, "position": 34, "face": 0, @@ -2015,6 +2020,7 @@ "platform": 1, "name": "test1-spine2", "serial": "45649818158", + "site": 1, "rack": 2, "position": 33, "face": 0, @@ -2035,6 +2041,7 @@ "platform": 1, "name": "test1-edge2", "serial": "7567356345", + "site": 1, "rack": 2, "position": 1, "face": 0, @@ -2055,6 +2062,7 @@ "platform": 1, "name": "test1-core2", "serial": "67856734534", + "site": 1, "rack": 2, "position": 17, "face": 0, @@ -2075,6 +2083,7 @@ "platform": 2, "name": "test1-oob1", "serial": "98273942938", + "site": 1, "rack": 1, "position": 42, "face": 0, @@ -2095,6 +2104,7 @@ "platform": null, "name": "test1-pdu1", "serial": "", + "site": 1, "rack": 1, "position": null, "face": null, @@ -2115,6 +2125,7 @@ "platform": null, "name": "test1-pdu2", "serial": "", + "site": 1, "rack": 2, "position": null, "face": null, diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 64e8b57fa..a4fc39626 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -445,7 +445,7 @@ class PlatformForm(BootstrapMixin, forms.ModelForm): class DeviceForm(BootstrapMixin, CustomFieldForm): site = forms.ModelChoiceField(queryset=Site.objects.all(), widget=forms.Select(attrs={'filter-for': 'rack'})) - rack = forms.ModelChoiceField(queryset=Rack.objects.all(), widget=APISelect( + rack = forms.ModelChoiceField(queryset=Rack.objects.all(), required=False, widget=APISelect( api_url='/api/dcim/racks/?site_id={{site}}', display_field='display_name', attrs={'filter-for': 'position'} @@ -549,7 +549,7 @@ class DeviceForm(BootstrapMixin, CustomFieldForm): if pk and self.instance.device_type.is_child_device and hasattr(self.instance, 'parent_bay'): self.fields['site'].disabled = True self.fields['rack'].disabled = True - self.initial['site'] = self.instance.parent_bay.device.rack.site_id + self.initial['site'] = self.instance.parent_bay.device.site_id self.initial['rack'] = self.instance.parent_bay.device.rack_id @@ -585,7 +585,7 @@ 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() + rack_name = forms.CharField(required=False) face = forms.CharField(required=False) class Meta(BaseDeviceFromCSVForm.Meta): @@ -748,9 +748,13 @@ class ConsolePortCreateForm(BootstrapMixin, forms.Form): class ConsoleConnectionCSVForm(forms.Form): - console_server = FlexibleModelChoiceField(queryset=Device.objects.filter(device_type__is_console_server=True), - to_field_name='name', - error_messages={'invalid_choice': 'Console server not found'}) + console_server = FlexibleModelChoiceField( + queryset=Device.objects.filter(device_type__is_console_server=True), + to_field_name='name', + error_messages={ + 'invalid_choice': 'Console server not found', + } + ) cs_port = forms.CharField() device = FlexibleModelChoiceField(queryset=Device.objects.all(), to_field_name='name', error_messages={'invalid_choice': 'Device not found'}) @@ -815,22 +819,49 @@ class ConsoleConnectionImportForm(BootstrapMixin, BulkImportForm): class ConsolePortConnectionForm(BootstrapMixin, forms.ModelForm): - rack = forms.ModelChoiceField(queryset=Rack.objects.all(), label='Rack', required=False, - widget=forms.Select(attrs={'filter-for': 'console_server'})) - console_server = forms.ModelChoiceField(queryset=Device.objects.all(), label='Console Server', required=False, - widget=APISelect(api_url='/api/dcim/devices/?rack_id={{rack}}&is_console_server=True', - display_field='display_name', - attrs={'filter-for': 'cs_port'})) - livesearch = forms.CharField(required=False, label='Console Server', widget=Livesearch( - query_key='q', query_url='dcim-api:device_list', field_to_update='console_server') + site = forms.ModelChoiceField( + queryset=Site.objects.all(), + widget=forms.HiddenInput(), + ) + rack = forms.ModelChoiceField( + queryset=Rack.objects.all(), + label='Rack', + required=False, + widget=forms.Select( + attrs={'filter-for': 'console_server', 'nullable': 'true'} + ) + ) + console_server = forms.ModelChoiceField( + queryset=Device.objects.all(), + label='Console Server', + required=False, + widget=APISelect( + api_url='/api/dcim/devices/?site_id={{site}}&rack_id={{rack}}&is_console_server=True', + display_field='display_name', + attrs={'filter-for': 'cs_port'} + ) + ) + livesearch = forms.CharField( + required=False, + label='Console Server', + widget=Livesearch( + query_key='q', + query_url='dcim-api:device_list', + field_to_update='console_server', + ) + ) + cs_port = forms.ModelChoiceField( + queryset=ConsoleServerPort.objects.all(), + label='Port', + widget=APISelect( + api_url='/api/dcim/devices/{{console_server}}/console-server-ports/', + disabled_indicator='connected_console', + ) ) - cs_port = forms.ModelChoiceField(queryset=ConsoleServerPort.objects.all(), label='Port', - widget=APISelect(api_url='/api/dcim/devices/{{console_server}}/console-server-ports/', - disabled_indicator='connected_console')) class Meta: model = ConsolePort - fields = ['rack', 'console_server', 'livesearch', 'cs_port', 'connection_status'] + fields = ['site', 'rack', 'console_server', 'livesearch', 'cs_port', 'connection_status'] labels = { 'cs_port': 'Port', 'connection_status': 'Status', @@ -843,17 +874,22 @@ class ConsolePortConnectionForm(BootstrapMixin, forms.ModelForm): if not self.instance.pk: raise RuntimeError("ConsolePortConnectionForm must be initialized with an existing ConsolePort instance.") - self.fields['rack'].queryset = Rack.objects.filter(site=self.instance.device.rack.site) + self.initial['site'] = self.instance.device.site + self.fields['rack'].queryset = Rack.objects.filter(site=self.instance.device.site) self.fields['cs_port'].required = True self.fields['connection_status'].choices = CONNECTION_STATUS_CHOICES # Initialize console server choices if self.is_bound and self.data.get('rack'): - self.fields['console_server'].queryset = Device.objects.filter(rack=self.data['rack'], device_type__is_console_server=True) + self.fields['console_server'].queryset = Device.objects.filter(rack=self.data['rack'], + device_type__is_console_server=True) elif self.initial.get('rack'): - self.fields['console_server'].queryset = Device.objects.filter(rack=self.initial['rack'], device_type__is_console_server=True) + self.fields['console_server'].queryset = Device.objects.filter(rack=self.initial['rack'], + device_type__is_console_server=True) else: - self.fields['console_server'].choices = [] + self.fields['console_server'].queryset = Device.objects.filter(site=self.instance.device.site, + rack__isnull=True, + device_type__is_console_server=True) # Initialize CS port choices if self.is_bound: @@ -883,22 +919,56 @@ class ConsoleServerPortCreateForm(BootstrapMixin, forms.Form): class ConsoleServerPortConnectionForm(BootstrapMixin, forms.Form): - rack = forms.ModelChoiceField(queryset=Rack.objects.all(), label='Rack', required=False, - widget=forms.Select(attrs={'filter-for': 'device'})) - device = forms.ModelChoiceField(queryset=Device.objects.all(), label='Device', required=False, - widget=APISelect(api_url='/api/dcim/devices/?rack_id={{rack}}', - display_field='display_name', attrs={'filter-for': 'port'})) - livesearch = forms.CharField(required=False, label='Device', widget=Livesearch( - query_key='q', query_url='dcim-api:device_list', field_to_update='device') + site = forms.ModelChoiceField( + queryset=Site.objects.all(), + widget=forms.HiddenInput(), + ) + rack = forms.ModelChoiceField( + queryset=Rack.objects.all(), + label='Rack', + required=False, + widget=forms.Select( + attrs={'filter-for': 'device', 'nullable': 'true'} + ) + ) + device = forms.ModelChoiceField( + queryset=Device.objects.all(), + label='Device', + required=False, + widget=APISelect( + api_url='/api/dcim/devices/?site_id={{site}}&rack_id={{rack}}', + display_field='display_name', + attrs={'filter-for': 'port'} + ) + ) + livesearch = forms.CharField( + required=False, + label='Device', + widget=Livesearch( + query_key='q', + query_url='dcim-api:device_list', + field_to_update='device' + ) + ) + port = forms.ModelChoiceField( + queryset=ConsolePort.objects.all(), + label='Port', + widget=APISelect( + api_url='/api/dcim/devices/{{device}}/console-ports/', + disabled_indicator='cs_port' + ) + ) + connection_status = forms.BooleanField( + required=False, + initial=CONNECTION_STATUS_CONNECTED, + label='Status', + widget=forms.Select( + choices=CONNECTION_STATUS_CHOICES + ) ) - port = forms.ModelChoiceField(queryset=ConsolePort.objects.all(), label='Port', - widget=APISelect(api_url='/api/dcim/devices/{{device}}/console-ports/', - disabled_indicator='cs_port')) - connection_status = forms.BooleanField(required=False, initial=CONNECTION_STATUS_CONNECTED, label='Status', - widget=forms.Select(choices=CONNECTION_STATUS_CHOICES)) class Meta: - fields = ['rack', 'device', 'livesearch', 'port', 'connection_status'] + fields = ['site', 'rack', 'device', 'livesearch', 'port', 'connection_status'] labels = { 'connection_status': 'Status', } @@ -907,7 +977,8 @@ class ConsoleServerPortConnectionForm(BootstrapMixin, forms.Form): super(ConsoleServerPortConnectionForm, self).__init__(*args, **kwargs) - self.fields['rack'].queryset = Rack.objects.filter(site=consoleserverport.device.rack.site) + self.initial['site'] = consoleserverport.device.site + self.fields['rack'].queryset = Rack.objects.filter(site=consoleserverport.device.site) # Initialize device choices if self.is_bound and self.data.get('rack'): @@ -915,7 +986,8 @@ class ConsoleServerPortConnectionForm(BootstrapMixin, forms.Form): elif self.initial.get('rack', None): self.fields['device'].queryset = Device.objects.filter(rack=self.initial['rack']) else: - self.fields['device'].choices = [] + self.fields['device'].queryset = Device.objects.filter(site=consoleserverport.device.site, + rack__isnull=True) # Initialize port choices if self.is_bound: @@ -945,8 +1017,13 @@ class PowerPortCreateForm(BootstrapMixin, forms.Form): class PowerConnectionCSVForm(forms.Form): - pdu = FlexibleModelChoiceField(queryset=Device.objects.filter(device_type__is_pdu=True), to_field_name='name', - error_messages={'invalid_choice': 'PDU not found.'}) + pdu = FlexibleModelChoiceField( + queryset=Device.objects.filter(device_type__is_pdu=True), + to_field_name='name', + error_messages={ + 'invalid_choice': 'PDU not found.', + } + ) power_outlet = forms.CharField() device = FlexibleModelChoiceField(queryset=Device.objects.all(), to_field_name='name', error_messages={'invalid_choice': 'Device not found'}) @@ -1012,21 +1089,46 @@ class PowerConnectionImportForm(BootstrapMixin, BulkImportForm): class PowerPortConnectionForm(BootstrapMixin, forms.ModelForm): - rack = forms.ModelChoiceField(queryset=Rack.objects.all(), label='Rack', required=False, - widget=forms.Select(attrs={'filter-for': 'pdu'})) - pdu = forms.ModelChoiceField(queryset=Device.objects.all(), label='PDU', required=False, - widget=APISelect(api_url='/api/dcim/devices/?rack_id={{rack}}&is_pdu=True', - display_field='display_name', attrs={'filter-for': 'power_outlet'})) - livesearch = forms.CharField(required=False, label='PDU', widget=Livesearch( - query_key='q', query_url='dcim-api:device_list', field_to_update='pdu') + site = forms.ModelChoiceField(queryset=Site.objects.all(), widget=forms.HiddenInput()) + rack = forms.ModelChoiceField( + queryset=Rack.objects.all(), + label='Rack', + required=False, + widget=forms.Select( + attrs={'filter-for': 'pdu', 'nullable': 'true'} + ) + ) + pdu = forms.ModelChoiceField( + queryset=Device.objects.all(), + label='PDU', + required=False, + widget=APISelect( + api_url='/api/dcim/devices/?site_id={{site}}&rack_id={{rack}}&is_pdu=True', + display_field='display_name', + attrs={'filter-for': 'power_outlet'} + ) + ) + livesearch = forms.CharField( + required=False, + label='PDU', + widget=Livesearch( + query_key='q', + query_url='dcim-api:device_list', + field_to_update='pdu' + ) + ) + power_outlet = forms.ModelChoiceField( + queryset=PowerOutlet.objects.all(), + label='Outlet', + widget=APISelect( + api_url='/api/dcim/devices/{{pdu}}/power-outlets/', + disabled_indicator='connected_port' + ) ) - power_outlet = forms.ModelChoiceField(queryset=PowerOutlet.objects.all(), label='Outlet', - widget=APISelect(api_url='/api/dcim/devices/{{pdu}}/power-outlets/', - disabled_indicator='connected_port')) class Meta: model = PowerPort - fields = ['rack', 'pdu', 'livesearch', 'power_outlet', 'connection_status'] + fields = ['site', 'rack', 'pdu', 'livesearch', 'power_outlet', 'connection_status'] labels = { 'power_outlet': 'Outlet', 'connection_status': 'Status', @@ -1039,17 +1141,22 @@ class PowerPortConnectionForm(BootstrapMixin, forms.ModelForm): if not self.instance.pk: raise RuntimeError("PowerPortConnectionForm must be initialized with an existing PowerPort instance.") - self.fields['rack'].queryset = Rack.objects.filter(site=self.instance.device.rack.site) + self.initial['site'] = self.instance.device.site + self.fields['rack'].queryset = Rack.objects.filter(site=self.instance.device.site) self.fields['power_outlet'].required = True self.fields['connection_status'].choices = CONNECTION_STATUS_CHOICES # Initialize PDU choices if self.is_bound and self.data.get('rack'): - self.fields['pdu'].queryset = Device.objects.filter(rack=self.data['rack'], device_type__is_pdu=True) + self.fields['pdu'].queryset = Device.objects.filter(rack=self.data['rack'], + device_type__is_pdu=True) elif self.initial.get('rack', None): - self.fields['pdu'].queryset = Device.objects.filter(rack=self.initial['rack'], device_type__is_pdu=True) + self.fields['pdu'].queryset = Device.objects.filter(rack=self.initial['rack'], + device_type__is_pdu=True) else: - self.fields['pdu'].choices = [] + self.fields['pdu'].queryset = Device.objects.filter(site=self.instance.device.site, + rack__isnull=True, + device_type__is_pdu=True) # Initialize power outlet choices if self.is_bound: @@ -1079,22 +1186,56 @@ class PowerOutletCreateForm(BootstrapMixin, forms.Form): class PowerOutletConnectionForm(BootstrapMixin, forms.Form): - rack = forms.ModelChoiceField(queryset=Rack.objects.all(), label='Rack', required=False, - widget=forms.Select(attrs={'filter-for': 'device'})) - device = forms.ModelChoiceField(queryset=Device.objects.all(), label='Device', required=False, - widget=APISelect(api_url='/api/dcim/devices/?rack_id={{rack}}', - display_field='display_name', attrs={'filter-for': 'port'})) - livesearch = forms.CharField(required=False, label='Device', widget=Livesearch( - query_key='q', query_url='dcim-api:device_list', field_to_update='device') + site = forms.ModelChoiceField( + queryset=Site.objects.all(), + widget=forms.HiddenInput() + ) + rack = forms.ModelChoiceField( + queryset=Rack.objects.all(), + label='Rack', + required=False, + widget=forms.Select( + attrs={'filter-for': 'device', 'nullable': 'true'} + ) + ) + device = forms.ModelChoiceField( + queryset=Device.objects.all(), + label='Device', + required=False, + widget=APISelect( + api_url='/api/dcim/devices/?site_id={{site}}&rack_id={{rack}}', + display_field='display_name', + attrs={'filter-for': 'port'} + ) + ) + livesearch = forms.CharField( + required=False, + label='Device', + widget=Livesearch( + query_key='q', + query_url='dcim-api:device_list', + field_to_update='device' + ) + ) + port = forms.ModelChoiceField( + queryset=PowerPort.objects.all(), + label='Port', + widget=APISelect( + api_url='/api/dcim/devices/{{device}}/power-ports/', + disabled_indicator='power_outlet' + ) + ) + connection_status = forms.BooleanField( + required=False, + initial=CONNECTION_STATUS_CONNECTED, + label='Status', + widget=forms.Select( + choices=CONNECTION_STATUS_CHOICES + ) ) - port = forms.ModelChoiceField(queryset=PowerPort.objects.all(), label='Port', - widget=APISelect(api_url='/api/dcim/devices/{{device}}/power-ports/', - disabled_indicator='power_outlet')) - connection_status = forms.BooleanField(required=False, initial=CONNECTION_STATUS_CONNECTED, label='Status', - widget=forms.Select(choices=CONNECTION_STATUS_CHOICES)) class Meta: - fields = ['rack', 'device', 'livesearch', 'port', 'connection_status'] + fields = ['site', 'rack', 'device', 'livesearch', 'port', 'connection_status'] labels = { 'connection_status': 'Status', } @@ -1103,7 +1244,8 @@ class PowerOutletConnectionForm(BootstrapMixin, forms.Form): super(PowerOutletConnectionForm, self).__init__(*args, **kwargs) - self.fields['rack'].queryset = Rack.objects.filter(site=poweroutlet.device.rack.site) + self.initial['site'] = poweroutlet.device.site + self.fields['rack'].queryset = Rack.objects.filter(site=poweroutlet.device.site) # Initialize device choices if self.is_bound and self.data.get('rack'): @@ -1111,7 +1253,8 @@ class PowerOutletConnectionForm(BootstrapMixin, forms.Form): elif self.initial.get('rack', None): self.fields['device'].queryset = Device.objects.filter(rack=self.initial['rack']) else: - self.fields['device'].choices = [] + self.fields['device'].queryset = Device.objects.filter(site=poweroutlet.device.site, + rack__isnull=True) # Initialize port choices if self.is_bound: @@ -1158,22 +1301,55 @@ class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm): # class InterfaceConnectionForm(BootstrapMixin, forms.ModelForm): - interface_a = forms.ChoiceField(choices=[], widget=SelectWithDisabled, label='Interface') - site_b = forms.ModelChoiceField(queryset=Site.objects.all(), label='Site', required=False, - widget=forms.Select(attrs={'filter-for': 'rack_b'})) - rack_b = forms.ModelChoiceField(queryset=Rack.objects.all(), label='Rack', required=False, - widget=APISelect(api_url='/api/dcim/racks/?site_id={{site_b}}', - attrs={'filter-for': 'device_b'})) - device_b = forms.ModelChoiceField(queryset=Device.objects.all(), label='Device', required=False, - widget=APISelect(api_url='/api/dcim/devices/?rack_id={{rack_b}}', - display_field='display_name', - attrs={'filter-for': 'interface_b'})) - livesearch = forms.CharField(required=False, label='Device', widget=Livesearch( - query_key='q', query_url='dcim-api:device_list', field_to_update='device_b') + interface_a = forms.ChoiceField( + choices=[], + widget=SelectWithDisabled, + label='Interface' + ) + site_b = forms.ModelChoiceField( + queryset=Site.objects.all(), + label='Site', + required=False, + widget=forms.Select( + attrs={'filter-for': 'rack_b'} + ) + ) + rack_b = forms.ModelChoiceField( + queryset=Rack.objects.all(), + label='Rack', + required=False, + widget=APISelect( + api_url='/api/dcim/racks/?site_id={{site_b}}', + attrs={'filter-for': 'device_b', 'nullable': 'true'} + ) + ) + device_b = forms.ModelChoiceField( + queryset=Device.objects.all(), + label='Device', + required=False, + widget=APISelect( + api_url='/api/dcim/devices/?site_id={{site_b}}&rack_id={{rack_b}}', + display_field='display_name', + attrs={'filter-for': 'interface_b'} + ) + ) + livesearch = forms.CharField( + required=False, + label='Device', + widget=Livesearch( + query_key='q', + query_url='dcim-api:device_list', + field_to_update='device_b' + ) + ) + interface_b = forms.ModelChoiceField( + queryset=Interface.objects.all(), + label='Interface', + widget=APISelect( + api_url='/api/dcim/devices/{{device_b}}/interfaces/?type=physical', + disabled_indicator='is_connected' + ) ) - interface_b = forms.ModelChoiceField(queryset=Interface.objects.all(), label='Interface', - widget=APISelect(api_url='/api/dcim/devices/{{device_b}}/interfaces/?type=physical', - disabled_indicator='is_connected')) class Meta: model = InterfaceConnection @@ -1198,11 +1374,15 @@ class InterfaceConnectionForm(BootstrapMixin, forms.ModelForm): else: self.fields['rack_b'].choices = [] - # Initialize device_b choices if rack_b is set + # Initialize device_b choices if rack_b or site_b is set if self.is_bound and self.data.get('rack_b'): self.fields['device_b'].queryset = Device.objects.filter(rack__pk=self.data['rack_b']) + elif self.is_bound and self.data.get('site_b'): + self.fields['device_b'].queryset = Device.objects.filter(site__pk=self.data['site_b'], rack__isnull=True) elif self.initial.get('rack_b'): self.fields['device_b'].queryset = Device.objects.filter(rack=self.initial['rack_b']) + elif self.initial.get('site_b'): + self.fields['device_b'].queryset = Device.objects.filter(site=self.initial['site_b'], rack__isnull=True) else: self.fields['device_b'].choices = [] @@ -1223,13 +1403,21 @@ class InterfaceConnectionForm(BootstrapMixin, forms.ModelForm): class InterfaceConnectionCSVForm(forms.Form): - device_a = FlexibleModelChoiceField(queryset=Device.objects.all(), to_field_name='name', - error_messages={'invalid_choice': 'Device A not found.'}) + device_a = FlexibleModelChoiceField( + queryset=Device.objects.all(), + to_field_name='name', + error_messages={'invalid_choice': 'Device A not found.'} + ) interface_a = forms.CharField() - device_b = FlexibleModelChoiceField(queryset=Device.objects.all(), to_field_name='name', - error_messages={'invalid_choice': 'Device B not found.'}) + device_b = FlexibleModelChoiceField( + queryset=Device.objects.all(), + to_field_name='name', + error_messages={'invalid_choice': 'Device B not found.'} + ) interface_b = forms.CharField() - status = forms.CharField(validators=[validate_connection_status]) + status = forms.CharField( + validators=[validate_connection_status] + ) def clean(self): diff --git a/netbox/dcim/migrations/0027_device_add_site.py b/netbox/dcim/migrations/0027_device_add_site.py new file mode 100644 index 000000000..12d85f53e --- /dev/null +++ b/netbox/dcim/migrations/0027_device_add_site.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.4 on 2017-02-16 21:21 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0026_add_rack_reservations'), + ] + + operations = [ + migrations.AddField( + model_name='device', + name='site', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='devices', to='dcim.Site'), + ), + ] diff --git a/netbox/dcim/migrations/0028_device_copy_rack_to_site.py b/netbox/dcim/migrations/0028_device_copy_rack_to_site.py new file mode 100644 index 000000000..6e7c52114 --- /dev/null +++ b/netbox/dcim/migrations/0028_device_copy_rack_to_site.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.4 on 2017-02-16 21:23 +from __future__ import unicode_literals + +from django.db import migrations + + +def copy_site_from_rack(apps, schema_editor): + Device = apps.get_model('dcim', 'Device') + for device in Device.objects.all(): + device.site = device.rack.site + device.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0027_device_add_site'), + ] + + operations = [ + migrations.RunPython(copy_site_from_rack), + ] diff --git a/netbox/dcim/migrations/0029_allow_rackless_devices.py b/netbox/dcim/migrations/0029_allow_rackless_devices.py new file mode 100644 index 000000000..83906fc76 --- /dev/null +++ b/netbox/dcim/migrations/0029_allow_rackless_devices.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.4 on 2017-02-16 21:25 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0028_device_copy_rack_to_site'), + ] + + operations = [ + migrations.AlterField( + model_name='device', + name='rack', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='devices', to='dcim.Rack'), + ), + migrations.AlterField( + model_name='device', + name='site', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='devices', to='dcim.Site'), + ), + ] diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 5ec28231f..64b0458b4 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -370,6 +370,19 @@ class Rack(CreatedUpdatedModel, CustomFieldModel): ) }) + def save(self, *args, **kwargs): + + # Record the original site assignment for this rack. + _site_id = None + if self.pk: + _site_id = Rack.objects.get(pk=self.pk).site_id + + super(Rack, self).save(*args, **kwargs) + + # Update racked devices if the assigned Site has been changed. + if _site_id is not None and self.site_id != _site_id: + Device.objects.filter(rack=self).update(site_id=self.site.pk) + def to_csv(self): return csv_format([ self.site.name, @@ -871,7 +884,8 @@ class Device(CreatedUpdatedModel, CustomFieldModel): serial = models.CharField(max_length=50, blank=True, verbose_name='Serial number') asset_tag = NullableCharField(max_length=50, blank=True, null=True, unique=True, verbose_name='Asset tag', help_text='A unique tag used to identify this device') - rack = models.ForeignKey('Rack', related_name='devices', on_delete=models.PROTECT) + site = models.ForeignKey('Site', related_name='devices', on_delete=models.PROTECT) + rack = models.ForeignKey('Rack', related_name='devices', blank=True, null=True, on_delete=models.PROTECT) position = models.PositiveSmallIntegerField(blank=True, null=True, validators=[MinValueValidator(1)], verbose_name='Position (U)', help_text='The lowest-numbered unit occupied by the device') @@ -898,41 +912,59 @@ class Device(CreatedUpdatedModel, CustomFieldModel): def clean(self): + # Validate site/rack combination + if self.rack and self.site != self.rack.site: + raise ValidationError({ + 'rack': "Rack {} does not belong to site {}.".format(self.rack, self.site), + }) + + if self.rack is None: + if self.face is not None: + raise ValidationError({ + 'face': "Cannot select a rack face without assigning a rack.", + }) + if self.position: + raise ValidationError({ + 'face': "Cannot select a rack position without assigning a rack.", + }) + # Validate position/face combination if self.position and self.face is None: raise ValidationError({ - 'face': "Must specify rack face when defining rack position." + 'face': "Must specify rack face when defining rack position.", }) - try: - # Child devices cannot be assigned to a rack face/unit - if self.device_type.is_child_device and self.face is not None: - raise ValidationError({ - 'face': "Child device types cannot be assigned to a rack face. This is an attribute of the parent " - "device." - }) - if self.device_type.is_child_device and self.position: - raise ValidationError({ - 'position': "Child device types cannot be assigned to a rack position. This is an attribute of the " - "parent device." - }) + if self.rack: - # Validate rack space - 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, - exclude=exclude_list) - if self.position and self.position not in available_units: + # Child devices cannot be assigned to a rack face/unit + if self.device_type.is_child_device and self.face is not None: raise ValidationError({ - 'position': "U{} is already occupied or does not have sufficient space to accommodate a(n) {} " - "({}U).".format(self.position, self.device_type, self.device_type.u_height) + 'face': "Child device types cannot be assigned to a rack face. This is an attribute of the parent " + "device." + }) + if self.device_type.is_child_device and self.position: + raise ValidationError({ + 'position': "Child device types cannot be assigned to a rack position. This is an attribute of the " + "parent device." }) - except Rack.DoesNotExist: - pass - except DeviceType.DoesNotExist: - pass + # Validate rack space + 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, + exclude=exclude_list) + if self.position and self.position not in available_units: + raise ValidationError({ + 'position': "U{} is already occupied or does not have sufficient space to accommodate a(n) {} " + "({}U).".format(self.position, self.device_type, self.device_type.u_height) + }) + except Rack.DoesNotExist: + pass + + except DeviceType.DoesNotExist: + pass def save(self, *args, **kwargs): @@ -980,8 +1012,8 @@ class Device(CreatedUpdatedModel, CustomFieldModel): self.platform.name if self.platform else None, self.serial, self.asset_tag, - self.rack.site.name, - self.rack.name, + self.site.name, + self.rack.name if self.rack else None, self.position, self.get_face_display(), ]) diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py index 442e2f8fb..0a891efea 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -311,8 +311,7 @@ class DeviceTable(BaseTable): status = tables.TemplateColumn(template_code=STATUS_ICON, verbose_name='') name = tables.TemplateColumn(template_code=DEVICE_LINK, verbose_name='Name') tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant') - site = tables.LinkColumn('dcim:site', accessor=Accessor('rack.site'), args=[Accessor('rack.site.slug')], - verbose_name='Site') + site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site') rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')], verbose_name='Rack') device_role = tables.TemplateColumn(DEVICE_ROLE, verbose_name='Role') device_type = tables.LinkColumn('dcim:devicetype', args=[Accessor('device_type.pk')], verbose_name='Type', @@ -328,8 +327,7 @@ class DeviceTable(BaseTable): class DeviceImportTable(BaseTable): name = tables.TemplateColumn(template_code=DEVICE_LINK, verbose_name='Name') tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant') - site = tables.LinkColumn('dcim:site', accessor=Accessor('rack.site'), args=[Accessor('rack.site.slug')], - verbose_name='Site') + site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site') rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')], verbose_name='Rack') position = tables.Column(verbose_name='Position') device_role = tables.Column(verbose_name='Role') diff --git a/netbox/dcim/tests/test_apis.py b/netbox/dcim/tests/test_apis.py index cec552984..604a952b7 100644 --- a/netbox/dcim/tests/test_apis.py +++ b/netbox/dcim/tests/test_apis.py @@ -346,6 +346,7 @@ class DeviceTest(APITestCase): 'platform', 'serial', 'asset_tag', + 'site', 'rack', 'position', 'face', @@ -417,6 +418,9 @@ class DeviceTest(APITestCase): 'primary_ip4_family', 'primary_ip4_id', 'primary_ip6', + 'site_id', + 'site_name', + 'site_slug', 'rack_display_name', 'rack_facility_id', 'rack_id', diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index 2f3d8def6..d1b721cb0 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -6,14 +6,14 @@ class RackTestCase(TestCase): def setUp(self): - site = Site.objects.create( + self.site = Site.objects.create( name='TestSite1', slug='my-test-site' ) self.rack = Rack.objects.create( name='TestRack1', facility_id='A101', - site=site, + site=self.site, u_height=42 ) self.manufacturer = Manufacturer.objects.create( @@ -56,29 +56,29 @@ class RackTestCase(TestCase): def test_mount_single_device(self): - rack1 = Rack.objects.get(name='TestRack1') device1 = Device( name='TestSwitch1', device_type=DeviceType.objects.get(manufacturer__slug='acme', slug='ff2048'), device_role=DeviceRole.objects.get(slug='switch'), - rack=rack1, + site=self.site, + rack=self.rack, position=10, face=RACK_FACE_REAR, ) device1.save() # Validate rack height - self.assertEqual(list(rack1.units), list(reversed(range(1, 43)))) + self.assertEqual(list(self.rack.units), list(reversed(range(1, 43)))) # Validate inventory (front face) - rack1_inventory_front = rack1.get_front_elevation() + rack1_inventory_front = self.rack.get_front_elevation() self.assertEqual(rack1_inventory_front[-10]['device'], device1) del(rack1_inventory_front[-10]) for u in rack1_inventory_front: self.assertIsNone(u['device']) # Validate inventory (rear face) - rack1_inventory_rear = rack1.get_rear_elevation() + rack1_inventory_rear = self.rack.get_rear_elevation() self.assertEqual(rack1_inventory_rear[-10]['device'], device1) del(rack1_inventory_rear[-10]) for u in rack1_inventory_rear: @@ -89,6 +89,7 @@ class RackTestCase(TestCase): name='TestPDU', device_role=self.role.get('PDU'), device_type=self.device_type.get('cc5000'), + site=self.site, rack=self.rack, position=None, face=None, diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 4bec35be9..df93e61f3 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -627,7 +627,7 @@ class PlatformBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # class DeviceListView(ObjectListView): - queryset = Device.objects.select_related('device_type__manufacturer', 'device_role', 'tenant', 'rack__site', + queryset = Device.objects.select_related('device_type__manufacturer', 'device_role', 'tenant', 'site', 'rack', 'primary_ip4', 'primary_ip6') filter = filters.DeviceFilter filter_form = forms.DeviceFilterForm @@ -1411,7 +1411,7 @@ def interfaceconnection_add(request, pk): else: form = forms.InterfaceConnectionForm(device, initial={ 'interface_a': request.GET.get('interface_a', None), - 'site_b': request.GET.get('site_b', device.rack.site), + 'site_b': request.GET.get('site_b', device.site), 'rack_b': request.GET.get('rack_b', None), 'device_b': request.GET.get('device_b', None), 'interface_b': request.GET.get('interface_b', None), diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 4b9d8ddf5..83be76169 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -307,10 +307,10 @@ class IPAddressForm(BootstrapMixin, CustomFieldForm): nat_inside = self.instance.nat_inside # If the IP is assigned to an interface, populate site/device fields accordingly if self.instance.nat_inside.interface: - self.initial['nat_site'] = self.instance.nat_inside.interface.device.rack.site.pk + self.initial['nat_site'] = self.instance.nat_inside.interface.device.site.pk self.initial['nat_device'] = self.instance.nat_inside.interface.device.pk self.fields['nat_device'].queryset = Device.objects.filter( - rack__site=nat_inside.interface.device.rack.site) + rack__site=nat_inside.interface.device.site) self.fields['nat_inside'].queryset = IPAddress.objects.filter( interface__device=nat_inside.interface.device) else: @@ -346,20 +346,54 @@ class IPAddressBulkAddForm(BootstrapMixin, forms.Form): class IPAddressAssignForm(BootstrapMixin, forms.Form): - site = forms.ModelChoiceField(queryset=Site.objects.all(), label='Site', required=False, - widget=forms.Select(attrs={'filter-for': 'rack'})) - rack = forms.ModelChoiceField(queryset=Rack.objects.all(), label='Rack', required=False, - widget=APISelect(api_url='/api/dcim/racks/?site_id={{site}}', - display_field='display_name', attrs={'filter-for': 'device'})) - device = forms.ModelChoiceField(queryset=Device.objects.all(), label='Device', required=False, - widget=APISelect(api_url='/api/dcim/devices/?rack_id={{rack}}', - display_field='display_name', attrs={'filter-for': 'interface'})) - livesearch = forms.CharField(required=False, label='Device', widget=Livesearch( - query_key='q', query_url='dcim-api:device_list', field_to_update='device') + site = forms.ModelChoiceField( + queryset=Site.objects.all(), + label='Site', + required=False, + widget=forms.Select( + attrs={'filter-for': 'rack'} + ) + ) + rack = forms.ModelChoiceField( + queryset=Rack.objects.all(), + label='Rack', + required=False, + widget=APISelect( + api_url='/api/dcim/racks/?site_id={{site}}', + display_field='display_name', + attrs={'filter-for': 'device', 'nullable': 'true'} + ) + ) + device = forms.ModelChoiceField( + queryset=Device.objects.all(), + label='Device', + required=False, + widget=APISelect( + api_url='/api/dcim/devices/?site_id={{site}}&rack_id={{rack}}', + display_field='display_name', + attrs={'filter-for': 'interface'} + ) + ) + livesearch = forms.CharField( + required=False, + label='Device', + widget=Livesearch( + query_key='q', + query_url='dcim-api:device_list', + field_to_update='device' + ) + ) + interface = forms.ModelChoiceField( + queryset=Interface.objects.all(), + label='Interface', + widget=APISelect( + api_url='/api/dcim/devices/{{device}}/interfaces/' + ) + ) + set_as_primary = forms.BooleanField( + label='Set as primary IP for device', + required=False ) - interface = forms.ModelChoiceField(queryset=Interface.objects.all(), label='Interface', - widget=APISelect(api_url='/api/dcim/devices/{{device}}/interfaces/')) - set_as_primary = forms.BooleanField(label='Set as primary IP for device', required=False) def __init__(self, *args, **kwargs): diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index b53bb82ab..191d33a90 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -559,6 +559,8 @@ def ipaddress_assign(request, pk): device.save() return redirect('ipam:ipaddress', pk=ipaddress.pk) + else: + assert False, form.errors else: form = forms.IPAddressAssignForm() diff --git a/netbox/project-static/js/forms.js b/netbox/project-static/js/forms.js index 5a736627e..e421f6283 100644 --- a/netbox/project-static/js/forms.js +++ b/netbox/project-static/js/forms.js @@ -68,38 +68,38 @@ $(document).ready(function() { }); // API select widget - $('select[filter-for]').change(function () { + $('select[filter-for]').change(function() { // Resolve child field by ID specified in parent var child_name = $(this).attr('filter-for'); var child_field = $('#id_' + child_name); var child_selected = child_field.val(); - // Wipe out any existing options within the child field + // Wipe out any existing options within the child field and create a default option child_field.empty(); - child_field.append($("").attr("value", "").text("")); - - if ($(this).val()) { + child_field.append($("").attr("value", "").text("---------")); + if ($(this).val() || $(this).attr('nullable') == 'true') { var api_url = child_field.attr('api-url'); var disabled_indicator = child_field.attr('disabled-indicator'); var initial_value = child_field.attr('initial'); var display_field = child_field.attr('display-field') || 'name'; - // Gather the values of all other filter fields for this child - $("select[filter-for='" + child_name + "']").each(function() { - var filter_field = $(this); + // Determine the filter fields needed to make an API call + var filter_regex = /\{\{([a-z_]+)\}\}/g; + var match; + while (match = filter_regex.exec(api_url)) { + var filter_field = $('#id_' + match[1]); if (filter_field.val()) { - api_url = api_url.replace('{{' + filter_field.attr('name') + '}}', filter_field.val()); - } else { - // Not all filters have been selected yet - return false; + api_url = api_url.replace(match[0], filter_field.val()); + } else if ($(this).attr('nullable') == 'true') { + api_url = api_url.replace(match[0], '0'); } - - }); + } // If all URL variables have been replaced, make the API call if (api_url.search('{{') < 0) { + console.log(child_name + ": Fetching " + api_url); $.ajax({ url: api_url, dataType: 'json', diff --git a/netbox/templates/dcim/consoleport_connect.html b/netbox/templates/dcim/consoleport_connect.html index c237bc2c9..f896ebbd5 100644 --- a/netbox/templates/dcim/consoleport_connect.html +++ b/netbox/templates/dcim/consoleport_connect.html @@ -7,6 +7,9 @@ {% block content %}
    {% csrf_token %} + {% for field in form.hidden_fields %} + {{ field }} + {% endfor %}
    {% if form.non_field_errors %} @@ -29,6 +32,12 @@ {% render_field form.livesearch %}
    +
    + +
    +

    {{ consoleport.device.site }}

    +
    +
    {% render_field form.rack %} {% render_field form.console_server %}
    diff --git a/netbox/templates/dcim/consoleserverport_connect.html b/netbox/templates/dcim/consoleserverport_connect.html index e747a9d57..08850d1ed 100644 --- a/netbox/templates/dcim/consoleserverport_connect.html +++ b/netbox/templates/dcim/consoleserverport_connect.html @@ -6,7 +6,10 @@ {% block content %} -{% csrf_token %} + {% csrf_token %} + {% for field in form.hidden_fields %} + {{ field }} + {% endfor %}
    {% if form.non_field_errors %} @@ -29,6 +32,12 @@ {% render_field form.livesearch %}
    +
    + +
    +

    {{ consoleserverport.device.site }}

    +
    +
    {% render_field form.rack %} {% render_field form.device %}
    diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index 4507e2141..cde8ce439 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -27,13 +27,17 @@
    @@ -44,9 +48,9 @@ U{{ parent.position }} / {{ parent.get_face_display }} ({{ parent }} - {{ device.parent_bay.name }}) {% endwith %} - {% elif device.position %} + {% elif device.rack and device.position %} U{{ device.position }} / {{ device.get_face_display }} - {% elif device.device_type.u_height %} + {% elif device.rack and device.device_type.u_height %} Not racked {% else %} N/A @@ -314,7 +318,11 @@ {{ rd }} diff --git a/netbox/templates/dcim/inc/device_header.html b/netbox/templates/dcim/inc/device_header.html index 74b453e1d..9f31c73fc 100644 --- a/netbox/templates/dcim/inc/device_header.html +++ b/netbox/templates/dcim/inc/device_header.html @@ -1,17 +1,17 @@
    - {% if device.rack %} -
    diff --git a/netbox/templates/dcim/interfaceconnection_edit.html b/netbox/templates/dcim/interfaceconnection_edit.html index ea30ad006..ad0335398 100644 --- a/netbox/templates/dcim/interfaceconnection_edit.html +++ b/netbox/templates/dcim/interfaceconnection_edit.html @@ -7,88 +7,88 @@ {% block content %}

    Connect Interfaces

    -{% csrf_token %} -
    -
    - {% if form.non_field_errors %} -
    -
    Errors
    + {% csrf_token %} +
    +
    + {% if form.non_field_errors %} +
    +
    Errors
    +
    + {{ form.non_field_errors }} +
    +
    + {% endif %} +
    +
    +
    +
    +
    +
    + A Side +
    - {{ form.non_field_errors }} -
    -
    - {% endif %} -
    -
    -
    -
    -
    -
    - A Side -
    -
    -
    - -
    -

    {{ device.rack.site }}

    +
    + +
    +

    {{ device.site }}

    +
    -
    -
    - -
    -

    {{ device.rack }}

    +
    + +
    +

    {{ device.rack|default:"None" }}

    +
    -
    -
    - -
    -

    {{ device }}

    +
    + +
    +

    {{ device }}

    +
    + {% render_field form.interface_a %}
    - {% render_field form.interface_a %}
    -
    -
    - -
    -
    -
    -
    - B Side -
    -
    - -
    - -
    - {% render_field form.site_b %} - {% render_field form.rack_b %} - {% render_field form.device_b %} -
    +
    + +
    +
    +
    +
    + B Side +
    +
    + +
    + +
    + {% render_field form.site_b %} + {% render_field form.rack_b %} + {% render_field form.device_b %} +
    +
    + {% render_field form.interface_b %}
    - {% render_field form.interface_b %}
    -
    -
    -
    -
    - {% render_field form.connection_status %}
    -
    -
    -
    - - - Cancel +
    +
    + {% render_field form.connection_status %} +
    +
    +
    +
    + + + Cancel +
    -
    {% endblock %} diff --git a/netbox/templates/dcim/poweroutlet_connect.html b/netbox/templates/dcim/poweroutlet_connect.html index a302722df..927ed7b71 100644 --- a/netbox/templates/dcim/poweroutlet_connect.html +++ b/netbox/templates/dcim/poweroutlet_connect.html @@ -6,7 +6,10 @@ {% block content %}
    -{% csrf_token %} + {% csrf_token %} + {% for field in form.hidden_fields %} + {{ field }} + {% endfor %}
    {% if form.non_field_errors %} @@ -29,6 +32,12 @@ {% render_field form.livesearch %}
    +
    + +
    +

    {{ poweroutlet.device.site }}

    +
    +
    {% render_field form.rack %} {% render_field form.device %}
    diff --git a/netbox/templates/dcim/powerport_connect.html b/netbox/templates/dcim/powerport_connect.html index 94e567e68..9e7f1fae9 100644 --- a/netbox/templates/dcim/powerport_connect.html +++ b/netbox/templates/dcim/powerport_connect.html @@ -6,7 +6,10 @@ {% block content %} -{% csrf_token %} + {% csrf_token %} + {% for field in form.hidden_fields %} + {{ field }} + {% endfor %}
    {% if form.non_field_errors %} @@ -29,6 +32,12 @@ {% render_field form.livesearch %}
    +
    + +
    +

    {{ powerport.device.site }}

    +
    +
    {% render_field form.rack %} {% render_field form.pdu %}
    From 102cf52a16abe405b8a84e20e8011afe4c58745e Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 17 Feb 2017 14:52:58 -0500 Subject: [PATCH 09/26] Cleanup from work on #198 --- netbox/dcim/forms.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index a4fc39626..f9e3dcf62 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -482,7 +482,7 @@ class DeviceForm(BootstrapMixin, CustomFieldForm): if self.instance.pk: # Initialize helper selections - self.initial['site'] = self.instance.rack.site + self.initial['site'] = self.instance.site self.initial['manufacturer'] = self.instance.device_type.manufacturer # Compile list of choices for primary IPv4 and IPv6 addresses From 4d26fc7e7ca5763f35760f8c1aea57076e932298 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 17 Feb 2017 15:10:08 -0500 Subject: [PATCH 10/26] Fixes #903: Only alert on missing criticial connections if present in the parent device type --- netbox/templates/dcim/device.html | 54 +++++++++++++++++-------------- 1 file changed, 30 insertions(+), 24 deletions(-) diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index cde8ce439..653575f2b 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -240,38 +240,44 @@ {% for iface in mgmt_interfaces %} {% include 'dcim/inc/interface.html' with icon='wrench' %} {% empty %} -
    - - + {% if device.device_type.interface_templates.exists %} + + + + {% endif %} {% endfor %} {% for cp in console_ports %} {% include 'dcim/inc/consoleport.html' %} {% empty %} - - - + {% if device.device_type.console_port_templates.exists %} + + + + {% endif %} {% endfor %} {% for pp in power_ports %} {% include 'dcim/inc/powerport.html' %} {% empty %} - - - + {% if device.device_type.power_port_templates.exists %} + + + + {% endif %} {% endfor %}
    Circuit IDTypeTenantA SideZ SideDescription
    @@ -136,6 +150,34 @@ {{ c.type }} + {% if c.tenant %} + {{ c.tenant }} + {% else %} + + {% endif %} + + {% if c.termination_a %} + {{ c.termination_a.site }} + {% else %} + + {% endif %} + + {% if c.termination_z %} + {{ c.termination_z.site }} + {% else %} + + {% endif %} + + {% if c.description %} + {{ c.description }} + {% else %} + + {% endif %} +
    Site - {{ device.rack.site }} + {{ device.site }}
    Rack - {{ device.rack.name }}{% if device.rack.facility_id %} ({{ device.rack.facility_id }}){% endif %} + {% if device.rack %} + {{ device.rack.name }}{% if device.rack.facility_id %} ({{ device.rack.facility_id }}){% endif %} + {% else %} + None + {% endif %}
    - Rack {{ rd.rack }} + {% if rd.rack %} + Rack {{ rd.rack }} + {% else %} + + {% endif %} {{ rd.device_type.full_name }}
    - No management interfaces defined - {% if perms.dcim.add_interface %} - - {% endif %} -
    + No management interfaces defined + {% if perms.dcim.add_interface %} + + {% endif %} +
    - No console ports defined - {% if perms.dcim.add_consoleport %} - - {% endif %} -
    + No console ports defined + {% if perms.dcim.add_consoleport %} + + {% endif %} +
    - No power ports defined - {% if perms.dcim.add_powerport %} - - {% endif %} -
    + No power ports defined + {% if perms.dcim.add_powerport %} + + {% endif %} +
    {% if perms.dcim.add_interface or perms.dcim.add_consoleport or perms.dcim.add_powerport %} From 0e5138d6ecc414eab3f668bf2b4de3b0cb0dbdee Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 17 Feb 2017 16:10:07 -0500 Subject: [PATCH 11/26] Fixes #872: TypeError on bulk IP address creation (Python 3) --- netbox/utilities/views.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index 9d1561a48..3fa09b829 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -307,11 +307,12 @@ class BulkAddView(View): if form.is_valid(): # The first field will be used as the pattern - pattern_field = form.fields.keys()[0] + field_names = list(form.fields.keys()) + pattern_field = field_names[0] pattern = form.cleaned_data[pattern_field] # All other fields will be copied as object attributes - kwargs = {k: form.cleaned_data[k] for k in form.fields.keys()[1:]} + kwargs = {k: form.cleaned_data[k] for k in field_names[1:]} new_objs = [] try: From b7f4a11eee1dfb0b3947fcc4642898c3029b89ab Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 17 Feb 2017 16:34:09 -0500 Subject: [PATCH 12/26] Fixes #892: Restored missing edit/delete buttons when viewing child prefixes and IP addresses from a parent object --- netbox/ipam/views.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 191d33a90..6eef522ec 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -297,9 +297,17 @@ def aggregate(request, pk): prefix_table.base_columns['pk'].visible = True RequestConfig(request, paginate={'klass': EnhancedPaginator}).configure(prefix_table) + # Compile permissions list for rendering the object table + permissions = { + 'add': request.user.has_perm('ipam.add_prefix'), + 'change': request.user.has_perm('ipam.change_prefix'), + 'delete': request.user.has_perm('ipam.delete_prefix'), + } + return render(request, 'ipam/aggregate.html', { 'aggregate': aggregate, 'prefix_table': prefix_table, + 'permissions': permissions, }) @@ -425,6 +433,13 @@ def prefix(request, pk): child_prefix_table.base_columns['pk'].visible = True RequestConfig(request, paginate={'klass': EnhancedPaginator}).configure(child_prefix_table) + # Compile permissions list for rendering the object table + permissions = { + 'add': request.user.has_perm('ipam.add_prefix'), + 'change': request.user.has_perm('ipam.change_prefix'), + 'delete': request.user.has_perm('ipam.delete_prefix'), + } + return render(request, 'ipam/prefix.html', { 'prefix': prefix, 'aggregate': aggregate, @@ -432,6 +447,7 @@ def prefix(request, pk): 'parent_prefix_table': parent_prefix_table, 'child_prefix_table': child_prefix_table, 'duplicate_prefix_table': duplicate_prefix_table, + 'permissions': permissions, 'return_url': prefix.get_absolute_url(), }) @@ -490,9 +506,17 @@ def prefix_ipaddresses(request, pk): ip_table.base_columns['pk'].visible = True RequestConfig(request, paginate={'klass': EnhancedPaginator}).configure(ip_table) + # Compile permissions list for rendering the object table + permissions = { + 'add': request.user.has_perm('ipam.add_ipaddress'), + 'change': request.user.has_perm('ipam.change_ipaddress'), + 'delete': request.user.has_perm('ipam.delete_ipaddress'), + } + return render(request, 'ipam/prefix_ipaddresses.html', { 'prefix': prefix, 'ip_table': ip_table, + 'permissions': permissions, }) From 7d1aeede1a94b1599cee9280b315d9532f5d535c Mon Sep 17 00:00:00 2001 From: Jasperswaagman Date: Tue, 21 Feb 2017 15:20:42 +0100 Subject: [PATCH 13/26] Typo --- docs/data-model/dcim.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/data-model/dcim.md b/docs/data-model/dcim.md index aa8673fb1..a345312d5 100644 --- a/docs/data-model/dcim.md +++ b/docs/data-model/dcim.md @@ -24,7 +24,7 @@ Each group is assigned to a parent site for easy navigation. Hierarchical recurs ### Rack Roles -Each rak can optionally be assigned to a functional role. For example, you might designate a rack for compute or storage resources, or to house colocated customer devices. +Each rack can optionally be assigned to a functional role. For example, you might designate a rack for compute or storage resources, or to house colocated customer devices. --- From aba9748ffd1a6bcb98efda777781ae5f14bfe821 Mon Sep 17 00:00:00 2001 From: Shawn Peng Date: Tue, 21 Feb 2017 10:27:24 -0800 Subject: [PATCH 14/26] Fix #235: Enable global vlan (#904) * Fix #235: Enable global vlan Decouple site/vlan, make site optional for vlan/vlangroup Change html generation code to check site existence before dereference Create site search function, if site is None for a VLAN, view it as global VLAN * commit1 * commit2 * commit3 * Add migration file for VLAN&VLAN group * Revert unintentional commits --- netbox/ipam/filters.py | 24 ++++++++++++++--- netbox/ipam/forms.py | 17 +++++++----- .../migrations/0015_auto_20170219_0726.py | 26 +++++++++++++++++++ netbox/ipam/models.py | 9 ++++--- netbox/templates/ipam/vlan.html | 14 ++++++++-- 5 files changed, 73 insertions(+), 17 deletions(-) create mode 100644 netbox/ipam/migrations/0015_auto_20170219_0726.py diff --git a/netbox/ipam/filters.py b/netbox/ipam/filters.py index a0dc9f633..558f5eade 100644 --- a/netbox/ipam/filters.py +++ b/netbox/ipam/filters.py @@ -262,37 +262,47 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet): class VLANGroupFilter(django_filters.FilterSet): - site_id = django_filters.ModelMultipleChoiceFilter( + site_id = NullableModelMultipleChoiceFilter( name='site', queryset=Site.objects.all(), label='Site (ID)', + method='site_search', ) - site = django_filters.ModelMultipleChoiceFilter( + site = NullableModelMultipleChoiceFilter( name='site__slug', queryset=Site.objects.all(), to_field_name='slug', label='Site (slug)', + method='site_search', ) class Meta: model = VLANGroup + def site_search(self, queryset, name, value): + q = Q(**{name: None}) + for v in value: + q |= Q(**{name: v}) + return queryset.filter(q) + class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet): q = django_filters.MethodFilter( action='search', label='Search', ) - site_id = django_filters.ModelMultipleChoiceFilter( + site_id = NullableModelMultipleChoiceFilter( name='site', queryset=Site.objects.all(), label='Site (ID)', + method='site_search', ) - site = django_filters.ModelMultipleChoiceFilter( + site = NullableModelMultipleChoiceFilter( name='site__slug', queryset=Site.objects.all(), to_field_name='slug', label='Site (slug)', + method='site_search', ) group_id = NullableModelMultipleChoiceFilter( name='group', @@ -349,6 +359,12 @@ class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet): pass return queryset.filter(qs_filter) + def site_search(self, queryset, name, value): + q = Q(**{name: None}) + for v in value: + q |= Q(**{name: v}) + return queryset.filter(q) + class ServiceFilter(django_filters.FilterSet): device_id = django_filters.ModelMultipleChoiceFilter( diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 83be76169..d6db8c9bc 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -153,7 +153,8 @@ class RoleForm(BootstrapMixin, forms.ModelForm): class PrefixForm(BootstrapMixin, CustomFieldForm): site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False, label='Site', - widget=forms.Select(attrs={'filter-for': 'vlan'})) + widget=forms.Select(attrs={'filter-for': 'vlan', + 'default_value': '0'})) vlan = forms.ModelChoiceField(queryset=VLAN.objects.all(), required=False, label='VLAN', widget=APISelect(api_url='/api/ipam/vlans/?site_id={{site}}', display_field='display_name')) @@ -173,7 +174,7 @@ class PrefixForm(BootstrapMixin, CustomFieldForm): elif self.initial.get('site'): self.fields['vlan'].queryset = VLAN.objects.filter(site=self.initial['site']) else: - self.fields['vlan'].choices = [] + self.fields['vlan'].queryset = VLAN.objects.filter(site=None) class PrefixFromCSVForm(forms.ModelForm): @@ -508,7 +509,8 @@ class VLANGroupForm(BootstrapMixin, forms.ModelForm): class VLANGroupFilterForm(BootstrapMixin, forms.Form): - site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('vlan_groups')), to_field_name='slug') + site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('vlan_groups')), to_field_name='slug', + null_option=(0, 'Global')) # @@ -532,7 +534,7 @@ class VLANForm(BootstrapMixin, CustomFieldForm): 'role': "The primary function of this VLAN", } widgets = { - 'site': forms.Select(attrs={'filter-for': 'group'}), + 'site': forms.Select(attrs={'filter-for': 'group', 'default_value': '0'}), } def __init__(self, *args, **kwargs): @@ -545,11 +547,11 @@ class VLANForm(BootstrapMixin, CustomFieldForm): elif self.initial.get('site'): self.fields['group'].queryset = VLANGroup.objects.filter(site=self.initial['site']) else: - self.fields['group'].choices = [] + self.fields['group'].queryset = VLANGroup.objects.filter(site=None) class VLANFromCSVForm(forms.ModelForm): - site = forms.ModelChoiceField(queryset=Site.objects.all(), to_field_name='name', + site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False, to_field_name='name', error_messages={'invalid_choice': 'Site not found.'}) group = forms.ModelChoiceField(queryset=VLANGroup.objects.all(), required=False, to_field_name='name', error_messages={'invalid_choice': 'VLAN group not found.'}) @@ -599,7 +601,8 @@ def vlan_status_choices(): class VLANFilterForm(BootstrapMixin, CustomFieldFilterForm): model = VLAN q = forms.CharField(required=False, label='Search') - site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('vlans')), to_field_name='slug') + site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('vlans')), to_field_name='slug', + null_option=(0, 'Global')) group_id = FilterChoiceField(queryset=VLANGroup.objects.annotate(filter_count=Count('vlans')), label='VLAN group', null_option=(0, 'None')) tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('vlans')), to_field_name='slug', diff --git a/netbox/ipam/migrations/0015_auto_20170219_0726.py b/netbox/ipam/migrations/0015_auto_20170219_0726.py new file mode 100644 index 000000000..38003b2b8 --- /dev/null +++ b/netbox/ipam/migrations/0015_auto_20170219_0726.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.5 on 2017-02-19 07:26 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('ipam', '0014_ipaddress_status_add_deprecated'), + ] + + operations = [ + migrations.AlterField( + model_name='vlan', + name='site', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='vlans', to='dcim.Site'), + ), + migrations.AlterField( + model_name='vlangroup', + name='site', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='vlan_groups', to='dcim.Site'), + ), + ] diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index d37fdec25..fa9e7834b 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -485,7 +485,7 @@ class VLANGroup(models.Model): """ name = models.CharField(max_length=50) slug = models.SlugField() - site = models.ForeignKey('dcim.Site', related_name='vlan_groups') + site = models.ForeignKey('dcim.Site', related_name='vlan_groups', on_delete=models.SET_NULL, blank=True, null=True) class Meta: ordering = ['site', 'name'] @@ -497,7 +497,8 @@ class VLANGroup(models.Model): verbose_name_plural = 'VLAN groups' def __str__(self): - return u'{} - {}'.format(self.site.name, self.name) + site_name = self.site.name if self.site else '__global' + return u'{} - {}'.format(site_name, self.name) def get_absolute_url(self): return "{}?group_id={}".format(reverse('ipam:vlan_list'), self.pk) @@ -513,7 +514,7 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel): Like Prefixes, each VLAN is assigned an operational status and optionally a user-defined Role. A VLAN can have zero or more Prefixes assigned to it. """ - site = models.ForeignKey('dcim.Site', related_name='vlans', on_delete=models.PROTECT) + site = models.ForeignKey('dcim.Site', related_name='vlans', on_delete=models.PROTECT, blank=True, null=True) group = models.ForeignKey('VLANGroup', related_name='vlans', blank=True, null=True, on_delete=models.PROTECT) vid = models.PositiveSmallIntegerField(verbose_name='ID', validators=[ MinValueValidator(1), @@ -551,7 +552,7 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel): def to_csv(self): return csv_format([ - self.site.name, + self.site.name if self.site else None, self.group.name if self.group else None, self.vid, self.name, diff --git a/netbox/templates/ipam/vlan.html b/netbox/templates/ipam/vlan.html index 3d81392e0..5d9f219fc 100644 --- a/netbox/templates/ipam/vlan.html +++ b/netbox/templates/ipam/vlan.html @@ -8,7 +8,11 @@