Browse Source

Merge branch 'develop' into api2

Conflicts:
	netbox/dcim/api/serializers.py
	netbox/dcim/api/urls.py
	netbox/dcim/api/views.py
	netbox/dcim/filters.py
Jeremy Stretch 8 years ago
parent
commit
b71566f206

+ 6 - 0
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:

+ 3 - 3
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):

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

+ 15 - 4
netbox/dcim/api/serializers.py

@@ -5,8 +5,8 @@ from dcim.models import (
     CONNECTION_STATUS_CHOICES, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device,
     DeviceBay, DeviceBayTemplate, DeviceType, DeviceRole, IFACE_FF_CHOICES, IFACE_ORDERING_CHOICES, Interface,
     InterfaceConnection, InterfaceTemplate, Manufacturer, Module, Platform, PowerOutlet, PowerOutletTemplate, PowerPort,
-    PowerPortTemplate, Rack, RackGroup, RackRole, RACK_FACE_CHOICES, RACK_TYPE_CHOICES, RACK_WIDTH_CHOICES, Site,
-    STATUS_CHOICES, SUBDEVICE_ROLE_CHOICES,
+    PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RACK_FACE_CHOICES, RACK_TYPE_CHOICES,
+    RACK_WIDTH_CHOICES, Site, STATUS_CHOICES, SUBDEVICE_ROLE_CHOICES,
 )
 from extras.api.serializers import CustomFieldModelSerializer
 from tenancy.api.serializers import NestedTenantSerializer
@@ -97,14 +97,13 @@ class NestedRackRoleSerializer(serializers.ModelSerializer):
 # Racks
 #
 
-
 class RackSerializer(CustomFieldModelSerializer):
     site = NestedSiteSerializer()
     group = NestedRackGroupSerializer()
     tenant = NestedTenantSerializer()
     role = NestedRackRoleSerializer()
     type = ChoiceFieldSerializer(choices=RACK_TYPE_CHOICES)
-    width = ChoiceFieldSerializer(choices=RACK_WIDTH_CHOICES)
+    # width = ChoiceFieldSerializer(choices=RACK_WIDTH_CHOICES)
 
     class Meta:
         model = Rack
@@ -133,6 +132,18 @@ class WritableRackSerializer(serializers.ModelSerializer):
 
 
 #
+# Rack reservations
+#
+
+class RackReservationSerializer(serializers.ModelSerializer):
+    rack = NestedRackSerializer()
+
+    class Meta:
+        model = RackReservation
+        fields = ['id', 'rack', 'units', 'created', 'user', 'description']
+
+
+#
 # Manufacturers
 #
 

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

@@ -16,6 +16,7 @@ router.register(r'sites', views.SiteViewSet)
 router.register(r'rack-groups', views.RackGroupViewSet)
 router.register(r'rack-roles', views.RackRoleViewSet)
 router.register(r'racks', views.RackViewSet)
+router.register(r'rack-reservations', views.RackReservationViewSet)
 
 # Device types
 router.register(r'manufacturers', views.ManufacturerViewSet)

+ 12 - 1
netbox/dcim/api/views.py

@@ -12,7 +12,8 @@ from django.shortcuts import get_object_or_404
 from dcim.models import (
     ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
     DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, Module,
-    Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackRole, Site,
+    Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation,
+    RackRole, Site,
 )
 from dcim import filters
 from extras.api.renderers import BINDZoneRenderer, FlatJSONRenderer
@@ -98,6 +99,16 @@ class RackViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
 
 
 #
+# Rack reservations
+#
+
+class RackReservationViewSet(ModelViewSet):
+    queryset = RackReservation.objects.all()
+    serializer_class = serializers.RackReservationSerializer
+    filter_class = filters.RackReservationFilter
+
+
+#
 # Manufacturers
 #
 

+ 14 - 1
netbox/dcim/filters.py

@@ -9,7 +9,8 @@ from utilities.filters import NullableModelMultipleChoiceFilter
 from .models import (
     ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
     DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, Module,
-    Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackRole, Site,
+    Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation,
+    RackRole, Site,
 )
 
 
@@ -123,6 +124,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

@@ -136,6 +136,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'),

+ 39 - 4
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
 #
 
@@ -1517,9 +1552,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
 
 

+ 3 - 3
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):

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

+ 4 - 1
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 = $("<option></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);
                         });

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

+ 4 - 4
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():