diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index bf390e17b..d08b1800a 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -147,6 +147,33 @@ class RackFilter(CustomFieldFilterSet, 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( name='rack', queryset=Rack.objects.all(), @@ -157,6 +184,16 @@ class RackReservationFilter(django_filters.FilterSet): model = RackReservation 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): id__in = NumericInFilter(name='id', lookup_expr='in') diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index e2cd829b8..32b8850f6 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -330,6 +330,19 @@ class RackReservationForm(BootstrapMixin, forms.ModelForm): 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 # diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 73678ae1c..901dc37ed 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -1,4 +1,5 @@ from collections import OrderedDict +from itertools import count, groupby from mptt.models import MPTTModel, TreeForeignKey @@ -571,6 +572,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 @@ -781,9 +791,9 @@ class InterfaceManager(models.Manager): 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), - 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 be parsed as follows: @@ -793,21 +803,23 @@ class InterfaceManager(models.Manager): subslot = 0 position = 1 channel = None + vc = 0 The chosen sorting method will determine which fields are ordered first in the query. """ queryset = self.get_queryset() sql_col = '{}.name'.format(queryset.model._meta.db_table) ordering = { - IFACE_ORDERING_POSITION: ('_slot', '_subslot', '_position', '_channel', '_name'), - IFACE_ORDERING_NAME: ('_name', '_slot', '_subslot', '_position', '_channel'), + IFACE_ORDERING_POSITION: ('_slot', '_subslot', '_position', '_channel', '_vc', '_name'), + IFACE_ORDERING_NAME: ('_name', '_slot', '_subslot', '_position', '_channel', '_vc'), }[method] return queryset.extra(select={ '_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), - '_subslot': "CAST(SUBSTRING({} FROM '([0-9]+)\/[0-9]+(:[0-9]+)?$') AS integer)".format(sql_col), - '_position': "CAST(SUBSTRING({} FROM '([0-9]+)(:[0-9]+)?$') AS integer)".format(sql_col), - '_channel': "CAST(SUBSTRING({} FROM ':([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]+)?(\.[0-9]+)?$') AS integer)".format(sql_col), + '_position': "CAST(SUBSTRING({} FROM '([0-9]+)(:[0-9]+)?(\.[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) diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py index 6af772fde..c56a20744 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -6,7 +6,7 @@ from utilities.tables import BaseTable, ToggleColumn from .models import ( ConsolePort, ConsolePortTemplate, ConsoleServerPortTemplate, Device, DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceTemplate, Manufacturer, Platform, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, - RackGroup, Region, Site, + RackGroup, RackReservation, Region, Site, ) @@ -64,6 +64,12 @@ RACK_ROLE = """ {% endif %} """ +RACKRESERVATION_ACTIONS = """ +{% if perms.dcim.change_rackreservation %} + +{% endif %} +""" + DEVICEROLE_ACTIONS = """ {% if perms.dcim.change_devicerole %} @@ -226,6 +232,23 @@ class RackImportTable(BaseTable): 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 # diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index 7fde6e9b3..c52807aa3 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -36,6 +36,8 @@ urlpatterns = [ url(r'^rack-roles/(?P\d+)/edit/$', views.RackRoleEditView.as_view(), name='rackrole_edit'), # 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\d+)/edit/$', views.RackReservationEditView.as_view(), name='rackreservation_edit'), url(r'^rack-reservations/(?P\d+)/delete/$', views.RackReservationDeleteView.as_view(), name='rackreservation_delete'), diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index f2b042599..8962d0219 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -360,6 +360,14 @@ class RackBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # 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): permission_required = 'dcim.change_rackreservation' model = RackReservation @@ -383,6 +391,12 @@ class RackReservationDeleteView(PermissionRequiredMixin, ObjectDeleteView): return obj.rack.get_absolute_url() +class RackReservationBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): + permission_required = 'dcim.delete_rackreservation' + cls = RackReservation + default_return_url = 'dcim:rackreservation_list' + + # # Manufacturers # diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 72b73208c..fef30d78f 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -586,27 +586,51 @@ class VLANForm(BootstrapMixin, CustomFieldForm): class VLANFromCSVForm(forms.ModelForm): - site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False, to_field_name='name', - error_messages={'invalid_choice': 'Site not found.'}) - group = forms.ModelChoiceField(queryset=VLANGroup.objects.all(), required=False, to_field_name='name', - error_messages={'invalid_choice': 'VLAN group not found.'}) - tenant = forms.ModelChoiceField(Tenant.objects.all(), to_field_name='name', required=False, - error_messages={'invalid_choice': 'Tenant not found.'}) + site = forms.ModelChoiceField( + queryset=Site.objects.all(), required=False, to_field_name='name', + error_messages={'invalid_choice': 'Site not found.'} + ) + group_name = forms.CharField(required=False) + 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]) - role = forms.ModelChoiceField(queryset=Role.objects.all(), required=False, to_field_name='name', - error_messages={'invalid_choice': 'Invalid role.'}) + role = forms.ModelChoiceField( + queryset=Role.objects.all(), required=False, to_field_name='name', + error_messages={'invalid_choice': 'Invalid role.'} + ) class Meta: 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): - 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 - 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'): - m.save() - return m + vlan.save() + return vlan class VLANImportForm(BootstrapMixin, BulkImportForm): diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 12377f9af..1afc374e9 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -12,7 +12,7 @@ except ImportError: "the documentation.") -VERSION = '1.9.4-r1' +VERSION = '1.9.5' # Import local configuration for setting in ['ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY']: diff --git a/netbox/netbox/urls.py b/netbox/netbox/urls.py index 9b1f81dad..bad9261da 100644 --- a/netbox/netbox/urls.py +++ b/netbox/netbox/urls.py @@ -1,3 +1,5 @@ +from rest_framework_swagger.views import get_swagger_view + from django.conf import settings from django.conf.urls import include, url from django.contrib import admin @@ -7,6 +9,7 @@ from users.views import login, logout handler500 = handle_500 +swagger_view = get_swagger_view(title='NetBox API') _patterns = [ @@ -31,7 +34,7 @@ _patterns = [ url(r'^api/ipam/', include('ipam.api.urls', namespace='ipam-api')), url(r'^api/secrets/', include('secrets.api.urls', namespace='secrets-api')), url(r'^api/tenancy/', include('tenancy.api.urls', namespace='tenancy-api')), - url(r'^api/docs/', include('rest_framework_swagger.urls')), + url(r'^api/docs/', swagger_view, name='api_docs'), url(r'^api-auth/', include('rest_framework.urls', namespace='rest_framework')), # Error testing diff --git a/netbox/templates/_base.html b/netbox/templates/_base.html index ba90f0106..800739b74 100644 --- a/netbox/templates/_base.html +++ b/netbox/templates/_base.html @@ -72,6 +72,8 @@ {% if perms.dcim.add_rackrole %}
  • Add a Rack Role
  • {% endif %} +
  • +
  • Rack Reservations