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
This commit is contained in:
Jeremy Stretch 2017-02-16 14:28:06 -05:00
commit b71566f206
21 changed files with 317 additions and 28 deletions

View File

@ -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 # 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) ## 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: This guide assumes that NetBox is installed at `/opt/netbox`. Pull down the most recent iteration of the master branch:

View File

@ -224,9 +224,9 @@ class CircuitTerminationEditView(PermissionRequiredMixin, ObjectEditView):
fields_initial = ['term_side'] fields_initial = ['term_side']
template_name = 'circuits/circuittermination_edit.html' template_name = 'circuits/circuittermination_edit.html'
def alter_obj(self, obj, args, kwargs): def alter_obj(self, obj, request, url_args, url_kwargs):
if 'circuit' in kwargs: if 'circuit' in url_kwargs:
obj.circuit = get_object_or_404(Circuit, pk=kwargs['circuit']) obj.circuit = get_object_or_404(Circuit, pk=url_kwargs['circuit'])
return obj return obj
def get_return_url(self, obj): def get_return_url(self, obj):

View File

@ -4,7 +4,7 @@ from django.db.models import Count
from .models import ( from .models import (
ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceTemplate, Manufacturer, Module, Platform, 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'] 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 # Device types
# #

View File

@ -5,8 +5,8 @@ from dcim.models import (
CONNECTION_STATUS_CHOICES, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, CONNECTION_STATUS_CHOICES, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device,
DeviceBay, DeviceBayTemplate, DeviceType, DeviceRole, IFACE_FF_CHOICES, IFACE_ORDERING_CHOICES, Interface, DeviceBay, DeviceBayTemplate, DeviceType, DeviceRole, IFACE_FF_CHOICES, IFACE_ORDERING_CHOICES, Interface,
InterfaceConnection, InterfaceTemplate, Manufacturer, Module, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, InterfaceConnection, InterfaceTemplate, Manufacturer, Module, Platform, PowerOutlet, PowerOutletTemplate, PowerPort,
PowerPortTemplate, Rack, RackGroup, RackRole, RACK_FACE_CHOICES, RACK_TYPE_CHOICES, RACK_WIDTH_CHOICES, Site, PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RACK_FACE_CHOICES, RACK_TYPE_CHOICES,
STATUS_CHOICES, SUBDEVICE_ROLE_CHOICES, RACK_WIDTH_CHOICES, Site, STATUS_CHOICES, SUBDEVICE_ROLE_CHOICES,
) )
from extras.api.serializers import CustomFieldModelSerializer from extras.api.serializers import CustomFieldModelSerializer
from tenancy.api.serializers import NestedTenantSerializer from tenancy.api.serializers import NestedTenantSerializer
@ -97,14 +97,13 @@ class NestedRackRoleSerializer(serializers.ModelSerializer):
# Racks # Racks
# #
class RackSerializer(CustomFieldModelSerializer): class RackSerializer(CustomFieldModelSerializer):
site = NestedSiteSerializer() site = NestedSiteSerializer()
group = NestedRackGroupSerializer() group = NestedRackGroupSerializer()
tenant = NestedTenantSerializer() tenant = NestedTenantSerializer()
role = NestedRackRoleSerializer() role = NestedRackRoleSerializer()
type = ChoiceFieldSerializer(choices=RACK_TYPE_CHOICES) type = ChoiceFieldSerializer(choices=RACK_TYPE_CHOICES)
width = ChoiceFieldSerializer(choices=RACK_WIDTH_CHOICES) # width = ChoiceFieldSerializer(choices=RACK_WIDTH_CHOICES)
class Meta: class Meta:
model = Rack model = Rack
@ -132,6 +131,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 # Manufacturers
# #

View File

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

View File

@ -12,7 +12,8 @@ from django.shortcuts import get_object_or_404
from dcim.models import ( from dcim.models import (
ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, Module, 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 dcim import filters
from extras.api.renderers import BINDZoneRenderer, FlatJSONRenderer from extras.api.renderers import BINDZoneRenderer, FlatJSONRenderer
@ -97,6 +98,16 @@ class RackViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
return Response(elevation) return Response(elevation)
#
# Rack reservations
#
class RackReservationViewSet(ModelViewSet):
queryset = RackReservation.objects.all()
serializer_class = serializers.RackReservationSerializer
filter_class = filters.RackReservationFilter
# #
# Manufacturers # Manufacturers
# #

View File

@ -9,7 +9,8 @@ from utilities.filters import NullableModelMultipleChoiceFilter
from .models import ( from .models import (
ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, Module, 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): class DeviceTypeFilter(CustomFieldFilterSet, django_filters.FilterSet):
q = django_filters.MethodFilter( q = django_filters.MethodFilter(
action='search', action='search',

View File

@ -1,6 +1,7 @@
import re import re
from django import forms from django import forms
from django.contrib.postgres.forms.array import SimpleArrayField
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db.models import Count, Q from django.db.models import Count, Q
@ -8,9 +9,9 @@ from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFi
from ipam.models import IPAddress from ipam.models import IPAddress
from tenancy.models import Tenant from tenancy.models import Tenant
from utilities.forms import ( from utilities.forms import (
APISelect, add_blank_choice, BootstrapMixin, BulkEditForm, BulkImportForm, CommentField, CSVDataField, APISelect, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm, BulkImportForm, CommentField,
ExpandableNameField, FilterChoiceField, FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SmallTextarea, CSVDataField, ExpandableNameField, FilterChoiceField, FlexibleModelChoiceField, Livesearch, SelectWithDisabled,
SlugField, SmallTextarea, SlugField,
) )
from .formfields import MACAddressFormField from .formfields import MACAddressFormField
@ -19,7 +20,7 @@ from .models import (
ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceRole, DeviceType, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceRole, DeviceType,
Interface, IFACE_FF_CHOICES, IFACE_FF_VIRTUAL, IFACE_ORDERING_CHOICES, InterfaceConnection, InterfaceTemplate, Interface, IFACE_FF_CHOICES, IFACE_FF_VIRTUAL, IFACE_ORDERING_CHOICES, InterfaceConnection, InterfaceTemplate,
Manufacturer, Module, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, RACK_TYPE_CHOICES, 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')) 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 # Manufacturers
# #

View File

@ -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'],
},
),
]

View File

@ -1,8 +1,10 @@
from collections import OrderedDict from collections import OrderedDict
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.contrib.contenttypes.fields import GenericRelation from django.contrib.contenttypes.fields import GenericRelation
from django.contrib.postgres.fields import ArrayField
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.core.validators import MaxValueValidator, MinValueValidator 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) 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 # Device Types
# #

View File

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

View File

@ -29,6 +29,10 @@ urlpatterns = [
url(r'^rack-roles/delete/$', views.RackRoleBulkDeleteView.as_view(), name='rackrole_bulk_delete'), 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'), 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 # Racks
url(r'^racks/$', views.RackListView.as_view(), name='rack_list'), url(r'^racks/$', views.RackListView.as_view(), name='rack_list'),
url(r'^racks/add/$', views.RackEditView.as_view(), name='rack_add'), 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+)/$', views.rack, name='rack'),
url(r'^racks/(?P<pk>\d+)/edit/$', views.RackEditView.as_view(), name='rack_edit'), 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<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 # Manufacturers
url(r'^manufacturers/$', views.ManufacturerListView.as_view(), name='manufacturer_list'), url(r'^manufacturers/$', views.ManufacturerListView.as_view(), name='manufacturer_list'),

View File

@ -26,7 +26,7 @@ from .models import (
CONNECTION_STATUS_CONNECTED, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, CONNECTION_STATUS_CONNECTED, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device,
DeviceBay, DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, DeviceBay, DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate,
Manufacturer, Module, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, 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() 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() 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', { return render(request, 'dcim/rack.html', {
'rack': rack, 'rack': rack,
'reservations': reservations,
'reserved_units': reserved_units,
'nonracked_devices': nonracked_devices, 'nonracked_devices': nonracked_devices,
'next_rack': next_rack, 'next_rack': next_rack,
'prev_rack': prev_rack, 'prev_rack': prev_rack,
@ -317,6 +325,33 @@ class RackBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
default_return_url = 'dcim:rack_list' 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 # Manufacturers
# #
@ -1517,9 +1552,9 @@ class ModuleEditView(PermissionRequiredMixin, ComponentEditView):
model = Module model = Module
form_class = forms.ModuleForm form_class = forms.ModuleForm
def alter_obj(self, obj, args, kwargs): def alter_obj(self, obj, request, url_args, url_kwargs):
if 'device' in kwargs: if 'device' in url_kwargs:
obj.device = get_object_or_404(Device, pk=kwargs['device']) obj.device = get_object_or_404(Device, pk=url_kwargs['device'])
return obj return obj

View File

@ -764,9 +764,9 @@ class ServiceEditView(PermissionRequiredMixin, ObjectEditView):
form_class = forms.ServiceForm form_class = forms.ServiceForm
template_name = 'ipam/service_edit.html' template_name = 'ipam/service_edit.html'
def alter_obj(self, obj, args, kwargs): def alter_obj(self, obj, request, url_args, url_kwargs):
if 'device' in kwargs: if 'device' in url_kwargs:
obj.device = get_object_or_404(Device, pk=kwargs['device']) obj.device = get_object_or_404(Device, pk=url_kwargs['device'])
return obj return obj
def get_return_url(self, obj): def get_return_url(self, obj):

View File

@ -264,6 +264,15 @@ ul.rack_far_face li.blocked {
#ffc7c7 14px #ffc7c7 14px
); );
} }
ul.rack_near_face li.reserved {
background: repeating-linear-gradient(
45deg,
#f7f7f7,
#f7f7f7 7px,
#c7c7ff 7px,
#c7c7ff 14px
);
}
ul.rack_near_face { ul.rack_near_face {
z-index: 200; z-index: 200;
} }

View File

@ -73,6 +73,7 @@ $(document).ready(function() {
// Resolve child field by ID specified in parent // Resolve child field by ID specified in parent
var child_name = $(this).attr('filter-for'); var child_name = $(this).attr('filter-for');
var child_field = $('#id_' + child_name); 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
child_field.empty(); child_field.empty();
@ -106,7 +107,9 @@ $(document).ready(function() {
$.each(response, function (index, choice) { $.each(response, function (index, choice) {
var option = $("<option></option>").attr("value", choice.id).text(choice[display_field]); var option = $("<option></option>").attr("value", choice.id).text(choice[display_field]);
if (disabled_indicator && choice[disabled_indicator] && choice.id != initial_value) { 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); child_field.append(option);
}); });

View File

@ -1,3 +1,5 @@
{% load helpers %}
<ul class="rack_legend"> <ul class="rack_legend">
{% for u in rack.units %} {% for u in rack.units %}
<li>{{ u }}</li> <li>{{ u }}</li>
@ -35,9 +37,14 @@
{% endifequal %} {% endifequal %}
</li> </li>
{% else %} {% else %}
<li class="available"> <li class="available{% if u.id in reserved_units.keys %} reserved{% endif %}">
{% if perms.dcim.add_device %} {% 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 %} {% endif %}
</li> </li>
{% endif %} {% endif %}

View File

@ -189,6 +189,51 @@
{% endif %} {% endif %}
</div> </div>
</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>
<div class="row col-md-6"> <div class="row col-md-6">
<div class="col-md-6 col-sm-6 col-xs-12"> <div class="col-md-6 col-sm-6 col-xs-12">

View File

@ -169,6 +169,27 @@ class SelectWithDisabled(forms.Select):
force_text(option_label)) 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): class APISelect(SelectWithDisabled):
""" """
A select widget populated via an API call A select widget populated via an API call

View File

@ -27,6 +27,14 @@ def getlist(value, arg):
return value.getlist(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) @register.filter(is_safe=True)
def gfm(value): def gfm(value):
""" """

View File

@ -145,9 +145,9 @@ class ObjectEditView(View):
return get_object_or_404(self.model, pk=kwargs['pk']) return get_object_or_404(self.model, pk=kwargs['pk'])
return self.model() 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 # 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 return obj
def get_return_url(self, obj): def get_return_url(self, obj):
@ -159,7 +159,7 @@ class ObjectEditView(View):
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
obj = self.get_object(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} 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) form = self.form_class(instance=obj, initial=initial_data)
@ -173,7 +173,7 @@ class ObjectEditView(View):
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
obj = self.get_object(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) form = self.form_class(request.POST, instance=obj)
if form.is_valid(): if form.is_valid():