Merge branch 'develop' into v2-develop

Conflicts:
	netbox/netbox/settings.py
	netbox/netbox/urls.py
	requirements.txt
This commit is contained in:
Jeremy Stretch 2017-04-06 17:01:13 -04:00
commit 57fc6a3f50
10 changed files with 164 additions and 23 deletions

View File

@ -148,6 +148,33 @@ class RackFilter(CustomFieldFilterSet, django_filters.FilterSet):
class RackReservationFilter(django_filters.FilterSet): class RackReservationFilter(django_filters.FilterSet):
id__in = NumericInFilter(name='id', lookup_expr='in')
q = django_filters.CharFilter(
method='search',
label='Search',
)
site_id = django_filters.ModelMultipleChoiceFilter(
name='rack__site',
queryset=Site.objects.all(),
label='Site (ID)',
)
site = django_filters.ModelMultipleChoiceFilter(
name='rack__site__slug',
queryset=Site.objects.all(),
to_field_name='slug',
label='Site (slug)',
)
group_id = NullableModelMultipleChoiceFilter(
name='rack__group',
queryset=RackGroup.objects.all(),
label='Group (ID)',
)
group = NullableModelMultipleChoiceFilter(
name='rack__group',
queryset=RackGroup.objects.all(),
to_field_name='slug',
label='Group',
)
rack_id = django_filters.ModelMultipleChoiceFilter( rack_id = django_filters.ModelMultipleChoiceFilter(
name='rack', name='rack',
queryset=Rack.objects.all(), queryset=Rack.objects.all(),
@ -158,6 +185,16 @@ class RackReservationFilter(django_filters.FilterSet):
model = RackReservation model = RackReservation
fields = ['rack', 'user'] fields = ['rack', 'user']
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(rack__name__icontains=value) |
Q(rack__facility_id__icontains=value) |
Q(user__username__icontains=value) |
Q(description__icontains=value)
)
class DeviceTypeFilter(CustomFieldFilterSet, django_filters.FilterSet): class DeviceTypeFilter(CustomFieldFilterSet, django_filters.FilterSet):
id__in = NumericInFilter(name='id', lookup_expr='in') id__in = NumericInFilter(name='id', lookup_expr='in')

View File

@ -330,6 +330,19 @@ class RackReservationForm(BootstrapMixin, forms.ModelForm):
return unit_choices return unit_choices
class RackReservationFilterForm(BootstrapMixin, forms.Form):
q = forms.CharField(required=False, label='Search')
site = FilterChoiceField(
queryset=Site.objects.annotate(filter_count=Count('racks__reservations')),
to_field_name='slug'
)
group_id = FilterChoiceField(
queryset=RackGroup.objects.select_related('site').annotate(filter_count=Count('racks__reservations')),
label='Rack group',
null_option=(0, 'None')
)
# #
# Manufacturers # Manufacturers
# #

View File

@ -1,4 +1,5 @@
from collections import OrderedDict from collections import OrderedDict
from itertools import count, groupby
from mptt.models import MPTTModel, TreeForeignKey from mptt.models import MPTTModel, TreeForeignKey
@ -575,6 +576,15 @@ class RackReservation(models.Model):
) )
}) })
@property
def unit_list(self):
"""
Express the assigned units as a string of summarized ranges. For example:
[0, 1, 2, 10, 14, 15, 16] => "0-2, 10, 14-16"
"""
group = (list(x) for _, x in groupby(sorted(self.units), lambda x, c=count(): next(c) - x))
return ', '.join('-'.join(map(str, (g[0], g[-1])[:len(g)])) for g in group)
# #
# Device Types # Device Types
@ -785,9 +795,9 @@ class InterfaceManager(models.Manager):
IFACE_ORDERING_CHOICES (typically indicated by a parent Device's DeviceType). IFACE_ORDERING_CHOICES (typically indicated by a parent Device's DeviceType).
To order interfaces naturally, the `name` field is split into five distinct components: leading text (name), To order interfaces naturally, the `name` field is split into five distinct components: leading text (name),
slot, subslot, position, and channel: slot, subslot, position, channel, and virtual circuit:
{name}{slot}/{subslot}/{position}:{channel} {name}{slot}/{subslot}/{position}:{channel}.{vc}
Components absent from the interface name are ignored. For example, an interface named GigabitEthernet0/1 would Components absent from the interface name are ignored. For example, an interface named GigabitEthernet0/1 would
be parsed as follows: be parsed as follows:
@ -797,21 +807,23 @@ class InterfaceManager(models.Manager):
subslot = 0 subslot = 0
position = 1 position = 1
channel = None channel = None
vc = 0
The chosen sorting method will determine which fields are ordered first in the query. The chosen sorting method will determine which fields are ordered first in the query.
""" """
queryset = self.get_queryset() queryset = self.get_queryset()
sql_col = '{}.name'.format(queryset.model._meta.db_table) sql_col = '{}.name'.format(queryset.model._meta.db_table)
ordering = { ordering = {
IFACE_ORDERING_POSITION: ('_slot', '_subslot', '_position', '_channel', '_name'), IFACE_ORDERING_POSITION: ('_slot', '_subslot', '_position', '_channel', '_vc', '_name'),
IFACE_ORDERING_NAME: ('_name', '_slot', '_subslot', '_position', '_channel'), IFACE_ORDERING_NAME: ('_name', '_slot', '_subslot', '_position', '_channel', '_vc'),
}[method] }[method]
return queryset.extra(select={ return queryset.extra(select={
'_name': "SUBSTRING({} FROM '^([^0-9]+)')".format(sql_col), '_name': "SUBSTRING({} FROM '^([^0-9]+)')".format(sql_col),
'_slot': "CAST(SUBSTRING({} FROM '([0-9]+)\/[0-9]+\/[0-9]+(:[0-9]+)?$') AS integer)".format(sql_col), '_slot': "CAST(SUBSTRING({} FROM '([0-9]+)\/[0-9]+\/[0-9]+(:[0-9]+)?(\.[0-9]+)?$') AS integer)".format(sql_col),
'_subslot': "CAST(SUBSTRING({} FROM '([0-9]+)\/[0-9]+(:[0-9]+)?$') AS integer)".format(sql_col), '_subslot': "CAST(SUBSTRING({} FROM '([0-9]+)\/[0-9]+(:[0-9]+)?(\.[0-9]+)?$') AS integer)".format(sql_col),
'_position': "CAST(SUBSTRING({} FROM '([0-9]+)(:[0-9]+)?$') AS integer)".format(sql_col), '_position': "CAST(SUBSTRING({} FROM '([0-9]+)(:[0-9]+)?(\.[0-9]+)?$') AS integer)".format(sql_col),
'_channel': "CAST(SUBSTRING({} FROM ':([0-9]+)$') AS integer)".format(sql_col), '_channel': "COALESCE(CAST(SUBSTRING({} FROM ':([0-9]+)(\.[0-9]+)?$') AS integer), 0)".format(sql_col),
'_vc': "COALESCE(CAST(SUBSTRING({} FROM '\.([0-9]+)$') AS integer), 0)".format(sql_col),
}).order_by(*ordering) }).order_by(*ordering)

View File

@ -6,7 +6,7 @@ from utilities.tables import BaseTable, SearchTable, ToggleColumn
from .models import ( from .models import (
ConsolePort, ConsolePortTemplate, ConsoleServerPortTemplate, Device, DeviceBayTemplate, DeviceRole, DeviceType, ConsolePort, ConsolePortTemplate, ConsoleServerPortTemplate, Device, DeviceBayTemplate, DeviceRole, DeviceType,
Interface, InterfaceTemplate, Manufacturer, Platform, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, Interface, InterfaceTemplate, Manufacturer, Platform, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack,
RackGroup, Region, Site, RackGroup, RackReservation, Region, Site,
) )
@ -64,6 +64,12 @@ RACK_ROLE = """
{% endif %} {% endif %}
""" """
RACKRESERVATION_ACTIONS = """
{% if perms.dcim.change_rackreservation %}
<a href="{% url 'dcim:rackreservation_edit' pk=record.pk %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
{% endif %}
"""
DEVICEROLE_ACTIONS = """ DEVICEROLE_ACTIONS = """
{% if perms.dcim.change_devicerole %} {% if perms.dcim.change_devicerole %}
<a href="{% url 'dcim:devicerole_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a> <a href="{% url 'dcim:devicerole_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
@ -247,6 +253,23 @@ class RackImportTable(BaseTable):
fields = ('site', 'group', 'name', 'facility_id', 'tenant', 'u_height') fields = ('site', 'group', 'name', 'facility_id', 'tenant', 'u_height')
#
# Rack reservations
#
class RackReservationTable(BaseTable):
pk = ToggleColumn()
rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')])
unit_list = tables.Column(orderable=False, verbose_name='Units')
actions = tables.TemplateColumn(
template_code=RACKRESERVATION_ACTIONS, attrs={'td': {'class': 'text-right'}}, verbose_name=''
)
class Meta(BaseTable.Meta):
model = RackReservation
fields = ('pk', 'rack', 'unit_list', 'user', 'created', 'description', 'actions')
# #
# Manufacturers # Manufacturers
# #

View File

@ -40,6 +40,8 @@ urlpatterns = [
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 # Rack reservations
url(r'^rack-reservations/$', views.RackReservationListView.as_view(), name='rackreservation_list'),
url(r'^rack-reservations/delete/$', views.RackReservationBulkDeleteView.as_view(), name='rackreservation_bulk_delete'),
url(r'^rack-reservations/(?P<pk>\d+)/edit/$', views.RackReservationEditView.as_view(), name='rackreservation_edit'), 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'), url(r'^rack-reservations/(?P<pk>\d+)/delete/$', views.RackReservationDeleteView.as_view(), name='rackreservation_delete'),

View File

@ -360,6 +360,14 @@ class RackBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# Rack reservations # Rack reservations
# #
class RackReservationListView(ObjectListView):
queryset = RackReservation.objects.all()
filter = filters.RackReservationFilter
filter_form = forms.RackReservationFilterForm
table = tables.RackReservationTable
template_name = 'dcim/rackreservation_list.html'
class RackReservationEditView(PermissionRequiredMixin, ObjectEditView): class RackReservationEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.change_rackreservation' permission_required = 'dcim.change_rackreservation'
model = RackReservation model = RackReservation
@ -383,6 +391,12 @@ class RackReservationDeleteView(PermissionRequiredMixin, ObjectDeleteView):
return obj.rack.get_absolute_url() return obj.rack.get_absolute_url()
class RackReservationBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_rackreservation'
cls = RackReservation
default_return_url = 'dcim:rackreservation_list'
# #
# Manufacturers # Manufacturers
# #

View File

@ -586,27 +586,51 @@ class VLANForm(BootstrapMixin, CustomFieldForm):
class VLANFromCSVForm(forms.ModelForm): class VLANFromCSVForm(forms.ModelForm):
site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False, to_field_name='name', site = forms.ModelChoiceField(
error_messages={'invalid_choice': 'Site not found.'}) queryset=Site.objects.all(), required=False, to_field_name='name',
group = forms.ModelChoiceField(queryset=VLANGroup.objects.all(), required=False, to_field_name='name', error_messages={'invalid_choice': 'Site not found.'}
error_messages={'invalid_choice': 'VLAN group not found.'}) )
tenant = forms.ModelChoiceField(Tenant.objects.all(), to_field_name='name', required=False, group_name = forms.CharField(required=False)
error_messages={'invalid_choice': 'Tenant not found.'}) tenant = forms.ModelChoiceField(
Tenant.objects.all(), to_field_name='name', required=False,
error_messages={'invalid_choice': 'Tenant not found.'}
)
status_name = forms.ChoiceField(choices=[(s[1], s[0]) for s in VLAN_STATUS_CHOICES]) status_name = forms.ChoiceField(choices=[(s[1], s[0]) for s in VLAN_STATUS_CHOICES])
role = forms.ModelChoiceField(queryset=Role.objects.all(), required=False, to_field_name='name', role = forms.ModelChoiceField(
error_messages={'invalid_choice': 'Invalid role.'}) queryset=Role.objects.all(), required=False, to_field_name='name',
error_messages={'invalid_choice': 'Invalid role.'}
)
class Meta: class Meta:
model = VLAN model = VLAN
fields = ['site', 'group', 'vid', 'name', 'tenant', 'status_name', 'role', 'description'] fields = ['site', 'group_name', 'vid', 'name', 'tenant', 'status_name', 'role', 'description']
def clean(self):
super(VLANFromCSVForm, self).clean()
# Validate VLANGroup
group_name = self.cleaned_data.get('group_name')
if group_name:
try:
vlan_group = VLANGroup.objects.get(site=self.cleaned_data.get('site'), name=group_name)
except VLANGroup.DoesNotExist:
self.add_error('group_name', "Invalid VLAN group {}.".format(group_name))
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
m = super(VLANFromCSVForm, self).save(commit=False)
vlan = super(VLANFromCSVForm, self).save(commit=False)
# Assign VLANGroup by site and name
if self.cleaned_data['group_name']:
vlan.group = VLANGroup.objects.get(site=self.cleaned_data['site'], name=self.cleaned_data['group_name'])
# Assign VLAN status by name # Assign VLAN status by name
m.status = dict(self.fields['status_name'].choices)[self.cleaned_data['status_name']] vlan.status = dict(self.fields['status_name'].choices)[self.cleaned_data['status_name']]
if kwargs.get('commit'): if kwargs.get('commit'):
m.save() vlan.save()
return m return vlan
class VLANImportForm(BootstrapMixin, BulkImportForm): class VLANImportForm(BootstrapMixin, BulkImportForm):

View File

@ -72,6 +72,8 @@
{% if perms.dcim.add_rackrole %} {% if perms.dcim.add_rackrole %}
<li><a href="{% url 'dcim:rackrole_add' %}"><i class="fa fa-plus" aria-hidden="true"></i> Add a Rack Role</a></li> <li><a href="{% url 'dcim:rackrole_add' %}"><i class="fa fa-plus" aria-hidden="true"></i> Add a Rack Role</a></li>
{% endif %} {% endif %}
<li class="divider"></li>
<li><a href="{% url 'dcim:rackreservation_list' %}"><i class="fa fa-search" aria-hidden="true"></i> Rack Reservations</a></li>
</ul> </ul>
</li> </li>
<li class="dropdown{% if request.path|contains:'/dcim/device,/dcim/manufacturers/,/dcim/platforms/' %} active{% endif %}"> <li class="dropdown{% if request.path|contains:'/dcim/device,/dcim/manufacturers/,/dcim/platforms/' %} active{% endif %}">

View File

@ -224,7 +224,7 @@
</tr> </tr>
{% for resv in reservations %} {% for resv in reservations %}
<tr> <tr>
<td>{{ resv.units|join:', ' }}</td> <td>{{ resv.unit_list }}</td>
<td> <td>
{{ resv.description }}<br /> {{ resv.description }}<br />
<small>{{ resv.user }} &middot; {{ resv.created }}</small> <small>{{ resv.user }} &middot; {{ resv.created }}</small>

View File

@ -0,0 +1,14 @@
{% extends '_base.html' %}
{% load helpers %}
{% block content %}
<h1>{% block title %}Rack Reservations{% endblock %}</h1>
<div class="row">
<div class="col-md-9">
{% include 'utilities/obj_table.html' with bulk_delete_url='dcim:rackreservation_bulk_delete' %}
</div>
<div class="col-md-3">
{% include 'inc/search_panel.html' %}
</div>
</div>
{% endblock %}