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' %}
+ 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 %}
+
+ {% for error in field.errors %}
+ - {{ error }}
+ {% endfor %}
+
+ {% endif %}
{% elif field|widget_type == 'textarea' %}