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
```
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:

View File

@ -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):

View File

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

View File

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

View File

@ -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)

View File

@ -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
@ -97,6 +98,16 @@ class RackViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
return Response(elevation)
#
# Rack reservations
#
class RackReservationViewSet(ModelViewSet):
queryset = RackReservation.objects.all()
serializer_class = serializers.RackReservationSerializer
filter_class = filters.RackReservationFilter
#
# Manufacturers
#

View File

@ -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',

View File

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

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

View File

@ -136,6 +136,7 @@ class RackTest(APITestCase):
'width',
'u_height',
'desc_units',
'reservations',
'comments',
'custom_fields',
'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/(?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'),

View File

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

View File

@ -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):

View File

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

View File

@ -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);
});

View File

@ -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 %}

View File

@ -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">

View File

@ -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

View File

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

View File

@ -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():