Parcourir la source

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
Jeremy Stretch il y a 8 ans
Parent
commit
181539651f

+ 6 - 1
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
 #

+ 22 - 3
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)
@@ -111,6 +118,18 @@ class RackDetailSerializer(RackSerializer):
 
 
 #
+# Rack reservations
+#
+
+class RackReservationSerializer(serializers.ModelSerializer):
+    rack = RackNestedSerializer()
+
+    class Meta:
+        model = RackReservation
+        fields = ['id', 'rack', 'units', 'created', 'user', 'description']
+
+
+#
 # Manufacturers
 #
 

+ 4 - 0
netbox/dcim/api/urls.py

@@ -27,6 +27,10 @@ urlpatterns = [
     url(r'^racks/(?P<pk>\d+)/$', RackDetailView.as_view(), name='rack_detail'),
     url(r'^racks/(?P<pk>\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<pk>\d+)/$', RackReservationDetailView.as_view(), name='rackreservation_detail'),
+
     # Manufacturers
     url(r'^manufacturers/$', ManufacturerListView.as_view(), name='manufacturer_list'),
     url(r'^manufacturers/(?P<pk>\d+)/$', ManufacturerDetailView.as_view(), name='manufacturer_detail'),

+ 23 - 1
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
@@ -135,6 +136,27 @@ class RackUnitListView(APIView):
 
 
 #
+# 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
 #
 

+ 13 - 1
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',

+ 33 - 4
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
 )
 
 
@@ -244,6 +245,34 @@ class RackFilterForm(BootstrapMixin, CustomFieldFilterForm):
 
 
 #
+# 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
 #
 

+ 33 - 0
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'],
+            },
+        ),
+    ]

+ 46 - 0
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
 #

+ 1 - 0
netbox/dcim/tests/test_apis.py

@@ -151,6 +151,7 @@ class RackTest(APITestCase):
         'width',
         'u_height',
         'desc_units',
+        'reservations',
         'comments',
         'custom_fields',
         'front_units',

+ 5 - 0
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<pk>\d+)/edit/$', views.RackRoleEditView.as_view(), name='rackrole_edit'),
 
+    # Rack reservations
+    url(r'^rack-reservations/(?P<pk>\d+)/edit/$', views.RackReservationEditView.as_view(), name='rackreservation_edit'),
+    url(r'^rack-reservations/(?P<pk>\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<pk>\d+)/$', views.rack, name='rack'),
     url(r'^racks/(?P<pk>\d+)/edit/$', views.RackEditView.as_view(), name='rack_edit'),
     url(r'^racks/(?P<pk>\d+)/delete/$', views.RackDeleteView.as_view(), name='rack_delete'),
+    url(r'^racks/(?P<rack>\d+)/reservations/add/$', views.RackReservationEditView.as_view(), name='rack_add_reservation'),
 
     # Manufacturers
     url(r'^manufacturers/$', views.ManufacturerListView.as_view(), name='manufacturer_list'),

+ 36 - 1
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,
@@ -318,6 +326,33 @@ class RackBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 
 
 #
+# 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
 #
 

+ 9 - 0
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;
 }

+ 9 - 2
netbox/templates/dcim/inc/rack_elevation.html

@@ -1,3 +1,5 @@
+{% load helpers %}
+
 <ul class="rack_legend">
     {% for u in rack.units %}
         <li>{{ u }}</li>
@@ -35,9 +37,14 @@
                     {% endifequal %}
                 </li>
             {% else %}
-                <li class="available">
+                <li class="available{% if u.id in reserved_units.keys %} reserved{% endif %}">
                     {% if perms.dcim.add_device %}
-                        <a href="{% url 'dcim:device_add' %}?site={{ rack.site.pk }}&rack={{ rack.pk }}&face={{ face_id }}&position={{ u.id }}" class="add_device" >add device</a>
+                        <a href="{% url 'dcim:device_add' %}?site={{ rack.site.pk }}&rack={{ rack.pk }}&face={{ face_id }}&position={{ u.id }}" class="add_device"
+                       {% if u.id in reserved_units.keys %}{% with reserved_units|getkey:u.id as resv %}
+                           data-toggle="popover" data-trigger="hover" data-container="body" data-html="true"
+                           data-content="{{ resv.description }}<br/><small>{{ resv.user }} &middot; {{ resv.created }}</small>"
+                       {% endwith %}{% endif %}
+                        >add device</a>
                     {% endif %}
                 </li>
             {% endif %}

+ 45 - 0
netbox/templates/dcim/rack.html

@@ -189,6 +189,51 @@
                 {% endif %}
             </div>
         </div>
+        <div class="panel panel-default">
+            <div class="panel-heading">
+                <strong>Reservations</strong>
+            </div>
+            {% if reservations %}
+                <table class="table table-hover panel-body">
+                    <tr>
+                        <th>Units</th>
+                        <th>Description</th>
+                        <th></th>
+                    </tr>
+                    {% for resv in reservations %}
+                        <tr>
+                            <td>{{ resv.units|join:', ' }}</td>
+                            <td>
+                                {{ resv.description }}<br />
+                                <small>{{ resv.user }} &middot; {{ resv.created }}</small>
+                            </td>
+                            <td class="text-right">
+                                {% if perms.change_rackreservation %}
+                                    <a href="{% url 'dcim:rackreservation_edit' pk=resv.pk %}" class="btn btn-warning btn-xs" title="Edit reservation">
+                                        <i class="glyphicon glyphicon-pencil" aria-hidden="true"></i>
+                                    </a>
+                                {% endif %}
+                                {% if perms.delete_rackreservation %}
+                                    <a href="{% url 'dcim:rackreservation_delete' pk=resv.pk %}" class="btn btn-danger btn-xs" title="Delete reservation">
+                                        <i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
+                                    </a>
+                                {% endif %}
+                            </td>
+                        </tr>
+                    {% endfor %}
+                </table>
+            {% else %}
+                <div class="panel-body text-muted">None</div>
+            {% endif %}
+            {% if perms.dcim.add_rackreservation %}
+                <div class="panel-footer text-right">
+                    <a href="{% url 'dcim:rack_add_reservation' rack=rack.pk %}" class="btn btn-primary btn-xs">
+                        <span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
+                        Add a reservation
+                    </a>
+                </div>
+            {% endif %}
+        </div>
 	</div>
     <div class="row col-md-6">
        <div class="col-md-6 col-sm-6 col-xs-12">

+ 21 - 0
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

+ 8 - 0
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):
     """