Просмотр исходного кода

Merge pull request #1054 from digitalocean/develop

Release v1.9.5
Jeremy Stretch 9 лет назад
Родитель
Сommit
e0ad2b4555

+ 37 - 0
netbox/dcim/filters.py

@@ -147,6 +147,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(),
@@ -157,6 +184,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
 
 
@@ -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
 # Device Types
@@ -781,9 +791,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:
@@ -793,21 +803,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, 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>
@@ -226,6 +232,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
 #
 #

+ 2 - 0
netbox/dcim/urls.py

@@ -36,6 +36,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):

+ 1 - 1
netbox/netbox/settings.py

@@ -12,7 +12,7 @@ except ImportError:
                                "the documentation.")
                                "the documentation.")
 
 
 
 
-VERSION = '1.9.4-r1'
+VERSION = '1.9.5'
 
 
 # Import local configuration
 # Import local configuration
 for setting in ['ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY']:
 for setting in ['ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY']:

+ 4 - 1
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 import settings
 from django.conf.urls import include, url
 from django.conf.urls import include, url
 from django.contrib import admin
 from django.contrib import admin
@@ -7,6 +9,7 @@ from users.views import login, logout
 
 
 
 
 handler500 = handle_500
 handler500 = handle_500
+swagger_view = get_swagger_view(title='NetBox API')
 
 
 _patterns = [
 _patterns = [
 
 
@@ -31,7 +34,7 @@ _patterns = [
     url(r'^api/ipam/', include('ipam.api.urls', namespace='ipam-api')),
     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/secrets/', include('secrets.api.urls', namespace='secrets-api')),
     url(r'^api/tenancy/', include('tenancy.api.urls', namespace='tenancy-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')),
     url(r'^api-auth/', include('rest_framework.urls', namespace='rest_framework')),
 
 
     # Error testing
     # Error testing

+ 3 - 1
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 %}">
@@ -294,7 +296,7 @@
                 <div class="col-xs-4 text-right">
                 <div class="col-xs-4 text-right">
                     <p class="text-muted">
                     <p class="text-muted">
                         <i class="fa fa-fw fa-book text-primary"></i> <a href="http://netbox.readthedocs.io/" target="_blank">Docs</a> &middot;
                         <i class="fa fa-fw fa-book text-primary"></i> <a href="http://netbox.readthedocs.io/" target="_blank">Docs</a> &middot;
-                        <i class="fa fa-fw fa-cloud text-primary"></i> <a href="{% url 'django.swagger.base.view' %}">API</a> &middot;
+                        <i class="fa fa-fw fa-cloud text-primary"></i> <a href="{% url 'api_docs' %}">API</a> &middot;
                         <i class="fa fa-fw fa-code text-primary"></i> <a href="https://github.com/digitalocean/netbox">Code</a> &middot;
                         <i class="fa fa-fw fa-code text-primary"></i> <a href="https://github.com/digitalocean/netbox">Code</a> &middot;
                         <i class="fa fa-fw fa-support text-primary"></i> <a href="https://github.com/digitalocean/netbox/wiki">Help</a>
                         <i class="fa fa-fw fa-support text-primary"></i> <a href="https://github.com/digitalocean/netbox/wiki">Help</a>
                     </p>
                     </p>

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

@@ -210,7 +210,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 %}

+ 2 - 2
requirements.txt

@@ -1,10 +1,10 @@
 cffi>=1.8
 cffi>=1.8
 cryptography>=1.4
 cryptography>=1.4
-Django>=1.10
+Django>=1.10,<1.11
 django-debug-toolbar>=1.6
 django-debug-toolbar>=1.6
 django-filter>=1.0.1
 django-filter>=1.0.1
 django-mptt==0.8.7
 django-mptt==0.8.7
-django-rest-swagger==0.3.10
+django-rest-swagger>=2.1.0
 django-tables2>=1.2.5
 django-tables2>=1.2.5
 djangorestframework>=3.5.0
 djangorestframework>=3.5.0
 graphviz>=0.4.10
 graphviz>=0.4.10