Browse Source

Merge branch 'develop' into v2-develop

Conflicts:
	netbox/netbox/settings.py
	netbox/netbox/urls.py
	requirements.txt
Jeremy Stretch 8 years ago
parent
commit
57fc6a3f50

+ 37 - 0
netbox/dcim/filters.py

@@ -148,6 +148,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(),
@@ -158,6 +185,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')

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

+ 20 - 8
netbox/dcim/models.py

@@ -1,4 +1,5 @@
 from collections import OrderedDict
+from itertools import count, groupby
 
 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
@@ -785,9 +795,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:
@@ -797,21 +807,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)
 
 

+ 24 - 1
netbox/dcim/tables.py

@@ -6,7 +6,7 @@ from utilities.tables import BaseTable, SearchTable, 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 %}
+    <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 = """
 {% 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>
@@ -248,6 +254,23 @@ class RackImportTable(BaseTable):
 
 
 #
+# 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
 #
 

+ 2 - 0
netbox/dcim/urls.py

@@ -40,6 +40,8 @@ urlpatterns = [
     url(r'^rack-roles/(?P<pk>\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<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'),
 

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

+ 37 - 13
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):

+ 2 - 0
netbox/templates/_base.html

@@ -72,6 +72,8 @@
                             {% 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>
                             {% 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>
                     </li>
                     <li class="dropdown{% if request.path|contains:'/dcim/device,/dcim/manufacturers/,/dcim/platforms/' %} active{% endif %}">

+ 1 - 1
netbox/templates/dcim/rack.html

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

+ 14 - 0
netbox/templates/dcim/rackreservation_list.html

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