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

Merge pull request #694 from digitalocean/develop

Release v1.7.1
Jeremy Stretch 9 лет назад
Родитель
Сommit
814c11167e

+ 1 - 1
Dockerfile

@@ -5,7 +5,7 @@ WORKDIR /opt/netbox
 ARG BRANCH=master
 ARG URL=https://github.com/digitalocean/netbox.git
 RUN git clone --depth 1 $URL -b $BRANCH .  && \
-    apt-get update -qq && apt-get install -y libldap2-dev libsasl2-dev libssl-dev && \
+    apt-get update -qq && apt-get install -y libldap2-dev libsasl2-dev libssl-dev graphviz && \
 	pip install gunicorn==17.5 && \
 	pip install django-auth-ldap && \
     pip install -r requirements.txt

+ 10 - 2
netbox/dcim/forms.py

@@ -268,6 +268,9 @@ class DeviceTypeBulkEditForm(BulkEditForm, BootstrapMixin):
     manufacturer = forms.ModelChoiceField(queryset=Manufacturer.objects.all(), required=False)
     u_height = forms.IntegerField(min_value=1, required=False)
 
+    class Meta:
+        nullable_fields = []
+
 
 class DeviceTypeFilterForm(forms.Form, BootstrapMixin):
     manufacturer = FilterChoiceField(queryset=Manufacturer.objects.annotate(filter_count=Count('device_types')),
@@ -1249,10 +1252,15 @@ class IPAddressForm(BootstrapMixin, CustomFieldForm):
 
         self.fields['vrf'].empty_label = 'Global'
 
-        self.fields['interface'].queryset = device.interfaces.all()
+        interfaces = device.interfaces.all()
+        self.fields['interface'].queryset = interfaces
         self.fields['interface'].required = True
 
-        # If this device does not have any IP addresses assigned, default to setting the first IP as its primary
+        # If this device has only one interface, select it by default.
+        if len(interfaces) == 1:
+            self.fields['interface'].initial = interfaces[0]
+
+        # If this device does not have any IP addresses assigned, default to setting the first IP as its primary.
         if not IPAddress.objects.filter(interface__device=device).count():
             self.fields['set_as_primary'].initial = True
 

+ 4 - 2
netbox/dcim/models.py

@@ -852,8 +852,7 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
                 'face': "Must specify rack face when defining rack position."
             })
 
-        if self.device_type:
-
+        try:
             # Child devices cannot be assigned to a rack face/unit
             if self.device_type.is_child_device and self.face is not None:
                 raise ValidationError({
@@ -880,6 +879,9 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
             except Rack.DoesNotExist:
                 pass
 
+        except DeviceType.DoesNotExist:
+            pass
+
     def save(self, *args, **kwargs):
 
         is_new = not bool(self.pk)

+ 9 - 7
netbox/ipam/forms.py

@@ -220,12 +220,11 @@ class PrefixFromCSVForm(forms.ModelForm):
             self.add_error('vlan_vid', "Must specify site and/or VLAN group when assigning a VLAN.")
 
     def save(self, *args, **kwargs):
-        m = super(PrefixFromCSVForm, self).save(commit=False)
+
         # Assign Prefix status by name
-        m.status = dict(self.fields['status_name'].choices)[self.cleaned_data['status_name']]
-        if kwargs.get('commit'):
-            m.save()
-        return m
+        self.instance.status = dict(self.fields['status_name'].choices)[self.cleaned_data['status_name']]
+
+        return super(PrefixFromCSVForm, self).save(*args, **kwargs)
 
 
 class PrefixImportForm(BulkImportForm, BootstrapMixin):
@@ -391,7 +390,10 @@ class IPAddressFromCSVForm(forms.ModelForm):
         if is_primary and not device:
             self.add_error('is_primary', "No device specified; cannot set as primary IP")
 
-    def save(self, commit=True):
+    def save(self, *args, **kwargs):
+
+        # Assign status by name
+        self.instance.status = dict(self.fields['status_name'].choices)[self.cleaned_data['status_name']]
 
         # Set interface
         if self.cleaned_data['device'] and self.cleaned_data['interface_name']:
@@ -404,7 +406,7 @@ class IPAddressFromCSVForm(forms.ModelForm):
             elif self.instance.address.version == 6:
                 self.instance.primary_ip6_for = self.cleaned_data['device']
 
-        return super(IPAddressFromCSVForm, self).save(commit=commit)
+        return super(IPAddressFromCSVForm, self).save(*args, **kwargs)
 
 
 class IPAddressImportForm(BulkImportForm, BootstrapMixin):

+ 20 - 10
netbox/ipam/models.py

@@ -22,23 +22,33 @@ AF_CHOICES = (
     (6, 'IPv6'),
 )
 
+PREFIX_STATUS_CONTAINER = 0
+PREFIX_STATUS_ACTIVE = 1
+PREFIX_STATUS_RESERVED = 2
+PREFIX_STATUS_DEPRECATED = 3
 PREFIX_STATUS_CHOICES = (
-    (0, 'Container'),
-    (1, 'Active'),
-    (2, 'Reserved'),
-    (3, 'Deprecated')
+    (PREFIX_STATUS_CONTAINER, 'Container'),
+    (PREFIX_STATUS_ACTIVE, 'Active'),
+    (PREFIX_STATUS_RESERVED, 'Reserved'),
+    (PREFIX_STATUS_DEPRECATED, 'Deprecated')
 )
 
+IPADDRESS_STATUS_ACTIVE = 1
+IPADDRESS_STATUS_RESERVED = 2
+IPADDRESS_STATUS_DHCP = 5
 IPADDRESS_STATUS_CHOICES = (
-    (1, 'Active'),
-    (2, 'Reserved'),
-    (5, 'DHCP')
+    (IPADDRESS_STATUS_ACTIVE, 'Active'),
+    (IPADDRESS_STATUS_RESERVED, 'Reserved'),
+    (IPADDRESS_STATUS_DHCP, 'DHCP')
 )
 
+VLAN_STATUS_ACTIVE = 1
+VLAN_STATUS_RESERVED = 2
+VLAN_STATUS_DEPRECATED = 3
 VLAN_STATUS_CHOICES = (
-    (1, 'Active'),
-    (2, 'Reserved'),
-    (3, 'Deprecated')
+    (VLAN_STATUS_ACTIVE, 'Active'),
+    (VLAN_STATUS_RESERVED, 'Reserved'),
+    (VLAN_STATUS_DEPRECATED, 'Deprecated')
 )
 
 STATUS_CHOICE_CLASSES = {

+ 31 - 2
netbox/ipam/tables.py

@@ -6,6 +6,25 @@ from utilities.tables import BaseTable, ToggleColumn
 from .models import Aggregate, IPAddress, Prefix, RIR, Role, VLAN, VLANGroup, VRF
 
 
+RIR_UTILIZATION = """
+<div class="progress">
+    {% if record.stats.total %}
+        <div class="progress-bar" role="progressbar" style="width: {{ record.stats.percentages.active }}%;">
+            <span class="sr-only">{{ record.stats.percentages.active }}%</span>
+        </div>
+        <div class="progress-bar progress-bar-info" role="progressbar" style="width: {{ record.stats.percentages.reserved }}%;">
+            <span class="sr-only">{{ record.stats.percentages.reserved }}%</span>
+        </div>
+        <div class="progress-bar progress-bar-danger" role="progressbar" style="width: {{ record.stats.percentages.deprecated }}%;">
+            <span class="sr-only">{{ record.stats.percentages.deprecated }}%</span>
+        </div>
+        <div class="progress-bar progress-bar-success" role="progressbar" style="width: {{ record.stats.percentages.available }}%;">
+            <span class="sr-only">{{ record.stats.percentages.available }}%</span>
+        </div>
+    {% endif %}
+</div>
+"""
+
 RIR_ACTIONS = """
 {% if perms.ipam.change_rir %}
     <a href="{% url 'ipam:rir_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
@@ -108,12 +127,22 @@ class RIRTable(BaseTable):
     pk = ToggleColumn()
     name = tables.LinkColumn(verbose_name='Name')
     aggregate_count = tables.Column(verbose_name='Aggregates')
-    slug = tables.Column(verbose_name='Slug')
+    stats_total = tables.Column(accessor='stats.total', verbose_name='Total',
+                                footer=lambda table: sum(r.stats['total'] for r in table.data))
+    stats_active = tables.Column(accessor='stats.active', verbose_name='Active',
+                                 footer=lambda table: sum(r.stats['active'] for r in table.data))
+    stats_reserved = tables.Column(accessor='stats.reserved', verbose_name='Reserved',
+                                   footer=lambda table: sum(r.stats['reserved'] for r in table.data))
+    stats_deprecated = tables.Column(accessor='stats.deprecated', verbose_name='Deprecated',
+                                     footer=lambda table: sum(r.stats['deprecated'] for r in table.data))
+    stats_available = tables.Column(accessor='stats.available', verbose_name='Available',
+                                    footer=lambda table: sum(r.stats['available'] for r in table.data))
+    utilization = tables.TemplateColumn(template_code=RIR_UTILIZATION, verbose_name='Utilization')
     actions = tables.TemplateColumn(template_code=RIR_ACTIONS, attrs={'td': {'class': 'text-right'}}, verbose_name='')
 
     class Meta(BaseTable.Meta):
         model = RIR
-        fields = ('pk', 'name', 'aggregate_count', 'slug', 'actions')
+        fields = ('pk', 'name', 'aggregate_count', 'stats_total', 'stats_active', 'stats_reserved', 'stats_deprecated', 'stats_available', 'utilization', 'actions')
 
 
 #

+ 79 - 2
netbox/ipam/views.py

@@ -1,5 +1,6 @@
-import netaddr
+from collections import OrderedDict
 from django_tables2 import RequestConfig
+import netaddr
 
 from django.contrib.auth.decorators import permission_required
 from django.contrib.auth.mixins import PermissionRequiredMixin
@@ -16,7 +17,7 @@ from utilities.views import (
 )
 
 from . import filters, forms, tables
-from .models import Aggregate, IPAddress, Prefix, RIR, Role, VLAN, VLANGroup, VRF
+from .models import Aggregate, IPAddress, PREFIX_STATUS_ACTIVE, PREFIX_STATUS_DEPRECATED, PREFIX_STATUS_RESERVED, Prefix, RIR, Role, VLAN, VLANGroup, VRF
 
 
 def add_available_prefixes(parent, prefix_list):
@@ -157,6 +158,82 @@ class RIRListView(ObjectListView):
     edit_permissions = ['ipam.change_rir', 'ipam.delete_rir']
     template_name = 'ipam/rir_list.html'
 
+    def alter_queryset(self, request):
+
+        if request.GET.get('family') == '6':
+            family = 6
+            denominator = 2 ** 64  # Count /64s for IPv6 rather than individual IPs
+        else:
+            family = 4
+            denominator = 1
+
+        rirs = []
+        for rir in self.queryset:
+
+            stats = {
+                'total': 0,
+                'active': 0,
+                'reserved': 0,
+                'deprecated': 0,
+                'available': 0,
+            }
+            aggregate_list = Aggregate.objects.filter(family=family, rir=rir)
+            for aggregate in aggregate_list:
+
+                queryset = Prefix.objects.filter(prefix__net_contained_or_equal=str(aggregate.prefix))
+
+                # Find all consumed space for each prefix status (we ignore containers for this purpose).
+                active_prefixes = netaddr.cidr_merge([p.prefix for p in queryset.filter(status=PREFIX_STATUS_ACTIVE)])
+                reserved_prefixes = netaddr.cidr_merge([p.prefix for p in queryset.filter(status=PREFIX_STATUS_RESERVED)])
+                deprecated_prefixes = netaddr.cidr_merge([p.prefix for p in queryset.filter(status=PREFIX_STATUS_DEPRECATED)])
+
+                # Find all available prefixes by subtracting each of the existing prefix sets from the aggregate prefix.
+                available_prefixes = (
+                    netaddr.IPSet([aggregate.prefix]) -
+                    netaddr.IPSet(active_prefixes) -
+                    netaddr.IPSet(reserved_prefixes) -
+                    netaddr.IPSet(deprecated_prefixes)
+                )
+
+                # Add the size of each metric to the RIR total.
+                stats['total'] += aggregate.prefix.size / denominator
+                stats['active'] += netaddr.IPSet(active_prefixes).size / denominator
+                stats['reserved'] += netaddr.IPSet(reserved_prefixes).size / denominator
+                stats['deprecated'] += netaddr.IPSet(deprecated_prefixes).size / denominator
+                stats['available'] += available_prefixes.size / denominator
+
+            # Calculate the percentage of total space for each prefix status.
+            total = float(stats['total'])
+            stats['percentages'] = {
+                'active': float('{:.2f}'.format(stats['active'] / total * 100)) if total else 0,
+                'reserved': float('{:.2f}'.format(stats['reserved'] / total * 100)) if total else 0,
+                'deprecated': float('{:.2f}'.format(stats['deprecated'] / total * 100)) if total else 0,
+            }
+            stats['percentages']['available'] = (
+                100 -
+                stats['percentages']['active'] -
+                stats['percentages']['reserved'] -
+                stats['percentages']['deprecated']
+            )
+            rir.stats = stats
+            rirs.append(rir)
+
+        return rirs
+
+    def extra_context(self):
+
+        totals = {
+            'total': sum([rir.stats['total'] for rir in self.queryset]),
+            'active': sum([rir.stats['active'] for rir in self.queryset]),
+            'reserved': sum([rir.stats['reserved'] for rir in self.queryset]),
+            'deprecated': sum([rir.stats['deprecated'] for rir in self.queryset]),
+            'available': sum([rir.stats['available'] for rir in self.queryset]),
+        }
+
+        return {
+            'totals': totals,
+        }
+
 
 class RIREditView(PermissionRequiredMixin, ObjectEditView):
     permission_required = 'ipam.change_rir'

+ 1 - 1
netbox/netbox/settings.py

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

+ 3 - 0
netbox/project-static/css/base.css

@@ -85,6 +85,9 @@ label.required {
 th.pk, td.pk {
     width: 30px;
 }
+tfoot td {
+    font-weight: bold;
+}
 
 /* Paginator */
 nav ul.pagination {

+ 9 - 11
netbox/templates/dcim/device.html

@@ -237,16 +237,14 @@
                 {% for pp in power_ports %}
                     {% include 'dcim/inc/_powerport.html' %}
                 {% empty %}
-                    {% if not device.device_type.is_pdu %}
-                        <tr>
-                            <td colspan="5" class="alert-warning">
-                                <i class="fa fa-fw fa-warning"></i> No power ports defined
-                                {% if perms.dcim.add_powerport %}
-                                    <a href="{% url 'dcim:powerport_add' pk=device.pk %}" class="btn btn-primary btn-xs pull-right"><span class="glyphicon glyphicon-plus" aria-hidden="true"></span></a>
-                                {% endif %}
-                            </td>
-                        </tr>
-                    {% endif %}
+                    <tr>
+                        <td colspan="5" class="alert-warning">
+                            <i class="fa fa-fw fa-warning"></i> No power ports defined
+                            {% if perms.dcim.add_powerport %}
+                                <a href="{% url 'dcim:powerport_add' pk=device.pk %}" class="btn btn-primary btn-xs pull-right"><span class="glyphicon glyphicon-plus" aria-hidden="true"></span></a>
+                            {% endif %}
+                        </td>
+                    </tr>
                 {% endfor %}
             </table>
             {% if perms.dcim.add_interface or perms.dcim.add_consoleport or perms.dcim.add_powerport %}
@@ -261,7 +259,7 @@
                             <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add console port
                         </a>
                     {% endif %}
-                    {% if perms.dcim.add_powerport and not device.device_type.is_pdu %}
+                    {% if perms.dcim.add_powerport %}
                         <a href="{% url 'dcim:powerport_add' pk=device.pk %}" class="btn btn-xs btn-primary">
                             <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add power port
                         </a>

+ 15 - 0
netbox/templates/ipam/rir_list.html

@@ -1,10 +1,22 @@
 {% extends '_base.html' %}
+{% load humanize %}
 {% load helpers %}
 
 {% block title %}RIRs{% endblock %}
 
 {% block content %}
 <div class="pull-right">
+    {% if request.GET.family == '6' %}
+        <a href="{% url 'ipam:rir_list' %}" class="btn btn-default">
+            <span class="fa fa-table" aria-hidden="true"></span>
+            IPv4 Stats
+        </a>
+    {% else %}
+        <a href="{% url 'ipam:rir_list' %}?family=6" class="btn btn-default">
+            <span class="fa fa-table" aria-hidden="true"></span>
+            IPv6 Stats
+        </a>
+    {% endif %}
     {% if perms.ipam.add_rir %}
         <a href="{% url 'ipam:rir_add' %}" class="btn btn-primary">
             <span class="fa fa-plus" aria-hidden="true"></span>
@@ -18,4 +30,7 @@
         {% include 'utilities/obj_table.html' with bulk_delete_url='ipam:rir_bulk_delete' %}
     </div>
 </div>
+{% if request.GET.family == '6' %}
+    <div class="pull-right text-muted"><strong>Note:</strong> Numbers shown indicate /64 prefixes.</div>
+{% endif %}
 {% endblock %}

+ 7 - 0
netbox/templates/utilities/render_field.html

@@ -16,6 +16,13 @@
                     <input type="checkbox" name="_nullify" value="{{ field.name }}" /> Set null
                 </label>
             {% endif %}
+            {% if field.errors %}
+                <ul>
+                    {% for error in field.errors %}
+                        <li class="text-danger">{{ error }}</li>
+                    {% endfor %}
+                </ul>
+            {% endif %}
         </div>
     {% elif field|widget_type == 'textarea' %}
         <div class="col-md-12">