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

+ 13 - 0
netbox/dcim/forms.py

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

+ 20 - 8
netbox/dcim/models.py

@@ -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_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]
         }[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),
-            '_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)
         }).order_by(*ordering)
 
 
 
 

+ 24 - 1
netbox/dcim/tables.py

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

+ 14 - 0
netbox/dcim/views.py

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

+ 37 - 13
netbox/ipam/forms.py

@@ -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',
-                                  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])
     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:
     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()
-        return m
+            vlan.save()
+        return vlan
 
 
 
 
 class VLANImportForm(BootstrapMixin, BulkImportForm):
 class VLANImportForm(BootstrapMixin, BulkImportForm):

+ 2 - 0
netbox/templates/_base.html

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

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

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

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