diff --git a/Dockerfile b/Dockerfile index 63562e2eaf2..dc28bb20b47 100644 --- a/Dockerfile +++ b/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 diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 6e95803f445..44e30e964c7 100644 --- a/netbox/dcim/forms.py +++ b/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 @@ def __init__(self, device, *args, **kwargs): 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 diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index b20f2294084..93cdb95d5c4 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -852,8 +852,7 @@ def clean(self): '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 @@ def clean(self): except Rack.DoesNotExist: pass + except DeviceType.DoesNotExist: + pass + def save(self, *args, **kwargs): is_new = not bool(self.pk) diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 958a99a3fc9..1a9e25db3d5 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -220,12 +220,11 @@ def clean(self): 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 @@ def clean(self): 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 @@ def save(self, commit=True): 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): diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index 163712d1e7e..1538251cff2 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -22,23 +22,33 @@ (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 = { diff --git a/netbox/ipam/tables.py b/netbox/ipam/tables.py index 6859472a653..f58dc66734b 100644 --- a/netbox/ipam/tables.py +++ b/netbox/ipam/tables.py @@ -6,6 +6,25 @@ from .models import Aggregate, IPAddress, Prefix, RIR, Role, VLAN, VLANGroup, VRF +RIR_UTILIZATION = """ +
+ {% if record.stats.total %} +
+ {{ record.stats.percentages.active }}% +
+
+ {{ record.stats.percentages.reserved }}% +
+
+ {{ record.stats.percentages.deprecated }}% +
+
+ {{ record.stats.percentages.available }}% +
+ {% endif %} +
+""" + RIR_ACTIONS = """ {% if perms.ipam.change_rir %} @@ -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') # diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 3262bbeb557..78bb1c1487e 100644 --- a/netbox/ipam/views.py +++ b/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 . 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' diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 27195c6d321..ccc94488b5d 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -12,7 +12,7 @@ "the documentation.") -VERSION = '1.7.0' +VERSION = '1.7.1' # Import local configuration for setting in ['ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY']: diff --git a/netbox/project-static/css/base.css b/netbox/project-static/css/base.css index 63574530904..635aa7e946e 100644 --- a/netbox/project-static/css/base.css +++ b/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 { diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index 62683930b94..14d5b086972 100644 --- a/netbox/templates/dcim/device.html +++ b/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 %} - - - No power ports defined - {% if perms.dcim.add_powerport %} - - {% endif %} - - - {% endif %} + + + No power ports defined + {% if perms.dcim.add_powerport %} + + {% endif %} + + {% endfor %} {% if perms.dcim.add_interface or perms.dcim.add_consoleport or perms.dcim.add_powerport %} @@ -261,7 +259,7 @@ Add console port {% endif %} - {% if perms.dcim.add_powerport and not device.device_type.is_pdu %} + {% if perms.dcim.add_powerport %} Add power port diff --git a/netbox/templates/ipam/rir_list.html b/netbox/templates/ipam/rir_list.html index 51d63f4c296..756e91d7f5b 100644 --- a/netbox/templates/ipam/rir_list.html +++ b/netbox/templates/ipam/rir_list.html @@ -1,10 +1,22 @@ {% extends '_base.html' %} +{% load humanize %} {% load helpers %} {% block title %}RIRs{% endblock %} {% block content %}
+ {% if request.GET.family == '6' %} + + + IPv4 Stats + + {% else %} + + + IPv6 Stats + + {% endif %} {% if perms.ipam.add_rir %} @@ -18,4 +30,7 @@

RIRs

{% include 'utilities/obj_table.html' with bulk_delete_url='ipam:rir_bulk_delete' %}
+{% if request.GET.family == '6' %} +
Note: Numbers shown indicate /64 prefixes.
+{% endif %} {% endblock %} diff --git a/netbox/templates/utilities/render_field.html b/netbox/templates/utilities/render_field.html index 09a91f752a9..21b5b905a87 100644 --- a/netbox/templates/utilities/render_field.html +++ b/netbox/templates/utilities/render_field.html @@ -16,6 +16,13 @@ Set null {% endif %} + {% if field.errors %} + + {% endif %} {% elif field|widget_type == 'textarea' %}