From 181539651ff36317ab48ea4aa3e07d78d1a7d928 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 16 Feb 2017 13:46:58 -0500 Subject: [PATCH] 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): """