From 3b76377cac2b5095fc5e3eae2d7d8b59d98b2f09 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 1 Aug 2016 13:44:22 -0400 Subject: [PATCH 01/14] Post-release version bump --- netbox/netbox/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 3ddbc8af5..e2a8ea3ef 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -12,7 +12,7 @@ except ImportError: "the documentation.") -VERSION = '1.4.0' +VERSION = '1.4.1-dev' # Import local configuration for setting in ['ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY']: From 3327954a3426983b192f44a0ed2d516815a05216 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 2 Aug 2016 10:27:58 -0400 Subject: [PATCH 02/14] Fixes #411: Corrected name of secret roles page --- netbox/templates/secrets/secretrole_list.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/templates/secrets/secretrole_list.html b/netbox/templates/secrets/secretrole_list.html index 55e12a1a8..4b1ee0399 100644 --- a/netbox/templates/secrets/secretrole_list.html +++ b/netbox/templates/secrets/secretrole_list.html @@ -12,7 +12,7 @@ {% endif %} -

Device Roles

+

Secret Roles

{% include 'utilities/obj_table.html' with bulk_delete_url='secrets:secretrole_bulk_delete' %} From 249faffe42b227bd48a55e2467fa8b68984fae2f Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 2 Aug 2016 10:39:42 -0400 Subject: [PATCH 03/14] Fixes #409: Filter IPs and prefixes by tenant slug rather than by its PK --- netbox/ipam/forms.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 0449efdb2..134c2933c 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -270,6 +270,11 @@ def prefix_vrf_choices(): return [(v.pk, u'{} ({})'.format(v.name, v.prefix_count)) for v in vrf_choices] +def tenant_choices(): + tenant_choices = Tenant.objects.all() + return [(t.slug, t.name) for t in tenant_choices] + + def prefix_site_choices(): site_choices = Site.objects.annotate(prefix_count=Count('prefixes')) return [(s.slug, u'{} ({})'.format(s.name, s.prefix_count)) for s in site_choices] @@ -291,8 +296,8 @@ class PrefixFilterForm(forms.Form, BootstrapMixin): parent = forms.CharField(required=False, label='Search Within') vrf = forms.MultipleChoiceField(required=False, choices=prefix_vrf_choices, label='VRF', widget=forms.SelectMultiple(attrs={'size': 6})) - tenant = forms.ModelMultipleChoiceField(queryset=Tenant.objects.all(), required=False, - widget=forms.SelectMultiple(attrs={'size': 6})) + tenant = forms.MultipleChoiceField(required=False, choices=tenant_choices, label='Tenant', + widget=forms.SelectMultiple(attrs={'size': 6})) status = forms.MultipleChoiceField(required=False, choices=prefix_status_choices, widget=forms.SelectMultiple(attrs={'size': 6})) site = forms.MultipleChoiceField(required=False, choices=prefix_site_choices, @@ -442,8 +447,8 @@ class IPAddressFilterForm(forms.Form, BootstrapMixin): family = forms.ChoiceField(required=False, choices=ipaddress_family_choices, label='Address Family') vrf = forms.MultipleChoiceField(required=False, choices=ipaddress_vrf_choices, label='VRF', widget=forms.SelectMultiple(attrs={'size': 6})) - tenant = forms.ModelMultipleChoiceField(queryset=Tenant.objects.all(), required=False, - widget=forms.SelectMultiple(attrs={'size': 6})) + tenant = forms.MultipleChoiceField(required=False, choices=tenant_choices, label='Tenant', + widget=forms.SelectMultiple(attrs={'size': 6})) # From d294e916a450fcd2bd0f69c99e1c803c221fa6ef Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 2 Aug 2016 10:50:25 -0400 Subject: [PATCH 04/14] Fixes #406: Corrected ordering of port_speed and commit_rate in CircuitTable --- netbox/circuits/tables.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/netbox/circuits/tables.py b/netbox/circuits/tables.py index 66cbd33ca..f82459890 100644 --- a/netbox/circuits/tables.py +++ b/netbox/circuits/tables.py @@ -57,9 +57,11 @@ class CircuitTable(BaseTable): provider = tables.LinkColumn('circuits:provider', args=[Accessor('provider.slug')], verbose_name='Provider') tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant') site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site') - port_speed_human = tables.Column(verbose_name='Port Speed') - commit_rate_human = tables.Column(verbose_name='Commit Rate') + port_speed = tables.Column(accessor=Accessor('port_speed_human'), order_by=Accessor('port_speed'), + verbose_name='Port Speed') + commit_rate = tables.Column(accessor=Accessor('commit_rate_human'), order_by=Accessor('commit_rate'), + verbose_name='Commit Rate') class Meta(BaseTable.Meta): model = Circuit - fields = ('pk', 'cid', 'type', 'provider', 'tenant', 'site', 'port_speed_human', 'commit_rate_human') + fields = ('pk', 'cid', 'type', 'provider', 'tenant', 'site', 'port_speed', 'commit_rate') From 9f3647cd53b29b482dd68cc40f11b922e0f81f3a Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 2 Aug 2016 15:48:12 -0400 Subject: [PATCH 05/14] Addresses #395: Show child prefixes from all VRFs if the parent prefix is in the global table --- netbox/ipam/tables.py | 3 ++- netbox/ipam/views.py | 13 ++++++++++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/netbox/ipam/tables.py b/netbox/ipam/tables.py index a7444b6b8..8ac21e04c 100644 --- a/netbox/ipam/tables.py +++ b/netbox/ipam/tables.py @@ -152,13 +152,14 @@ class PrefixTable(BaseTable): class PrefixBriefTable(BaseTable): prefix = tables.TemplateColumn(PREFIX_LINK_BRIEF, verbose_name='Prefix') + vrf = tables.LinkColumn('ipam:vrf', args=[Accessor('vrf.pk')], default='Global', verbose_name='VRF') site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site') status = tables.TemplateColumn(STATUS_LABEL, verbose_name='Status') role = tables.Column(verbose_name='Role') class Meta(BaseTable.Meta): model = Prefix - fields = ('prefix', 'status', 'site', 'role') + fields = ('prefix', 'vrf', 'status', 'site', 'role') orderable = False diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index dbb280c61..95aa33b1e 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -2,7 +2,7 @@ from netaddr import IPSet from django_tables2 import RequestConfig from django.contrib.auth.mixins import PermissionRequiredMixin -from django.db.models import Count +from django.db.models import Count, Q from django.shortcuts import get_object_or_404, render from dcim.models import Device @@ -281,7 +281,8 @@ def prefix(request, pk): .count() # Parent prefixes table - parent_prefixes = Prefix.objects.filter(vrf=prefix.vrf, prefix__net_contains=str(prefix.prefix))\ + parent_prefixes = Prefix.objects.filter(Q(vrf=prefix.vrf) | Q(vrf__isnull=True))\ + .filter(prefix__net_contains=str(prefix.prefix))\ .select_related('site', 'role').annotate_depth() parent_prefix_table = tables.PrefixBriefTable(parent_prefixes) @@ -291,7 +292,13 @@ def prefix(request, pk): duplicate_prefix_table = tables.PrefixBriefTable(duplicate_prefixes) # Child prefixes table - child_prefixes = Prefix.objects.filter(vrf=prefix.vrf, prefix__net_contained=str(prefix.prefix))\ + if prefix.vrf: + # If the prefix is in a VRF, show child prefixes only within that VRF. + child_prefixes = Prefix.objects.filter(vrf=prefix.vrf) + else: + # If the prefix is in the global table, show child prefixes from all VRFs. + child_prefixes = Prefix.objects.all() + child_prefixes = child_prefixes.filter(prefix__net_contained=str(prefix.prefix))\ .select_related('site', 'role').annotate_depth(limit=0) if child_prefixes: child_prefixes = add_available_prefixes(prefix.prefix, child_prefixes) From bc9158a74ff9606da2d5608681b130d97c6fb9a5 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 2 Aug 2016 16:04:25 -0400 Subject: [PATCH 06/14] Closes #412: Tenant group assignment is no longer mandatory --- docs/data-model/tenancy.md | 15 ++++++++++++- netbox/tenancy/forms.py | 16 ++++++++++++-- .../migrations/0002_tenant_group_optional.py | 21 +++++++++++++++++++ netbox/tenancy/models.py | 2 +- netbox/tenancy/views.py | 7 ++++--- 5 files changed, 54 insertions(+), 7 deletions(-) create mode 100644 netbox/tenancy/migrations/0002_tenant_group_optional.py diff --git a/docs/data-model/tenancy.md b/docs/data-model/tenancy.md index d3e8b8c29..c9cfc997c 100644 --- a/docs/data-model/tenancy.md +++ b/docs/data-model/tenancy.md @@ -4,6 +4,19 @@ NetBox supports the concept of individual tenants within its parent organization A tenant represents a discrete organization. Certain resources within NetBox can be assigned to a tenant. This makes it very convenient to track which resources are assigned to which customers, for instance. +The following objects can be assigned to tenants: + +* Sites +* Racks +* Devices +* VRFs +* Prefixes +* IP addresses +* VLANs +* Circuits + +If a prefix or IP address is not assigned to a tenant, it will appear to inherit the tenant to which its parent VRF is assigned, if any. + ### Tenant Groups -Tenants are grouped by type. For instance, you might create one group called "Customers" and one called "Acquisitions." +Tenants can be grouped by type. For instance, you might create one group called "Customers" and one called "Acquisitions." The assignment of tenants to groups is optional. diff --git a/netbox/tenancy/forms.py b/netbox/tenancy/forms.py index bf04454ba..eaf95ab2c 100644 --- a/netbox/tenancy/forms.py +++ b/netbox/tenancy/forms.py @@ -8,6 +8,18 @@ from utilities.forms import ( from .models import Tenant, TenantGroup +def bulkedit_tenantgroup_choices(): + """ + Include an option to remove the currently assigned TenantGroup from a Tenant. + """ + choices = [ + (None, '---------'), + (0, 'None'), + ] + choices += [(g.pk, g.name) for g in TenantGroup.objects.all()] + return choices + + def bulkedit_tenant_choices(): """ Include an option to remove the currently assigned Tenant from an object. @@ -46,7 +58,7 @@ class TenantForm(forms.ModelForm, BootstrapMixin): class TenantFromCSVForm(forms.ModelForm): - group = forms.ModelChoiceField(TenantGroup.objects.all(), to_field_name='name', + group = forms.ModelChoiceField(TenantGroup.objects.all(), required=False, to_field_name='name', error_messages={'invalid_choice': 'Group not found.'}) class Meta: @@ -60,7 +72,7 @@ class TenantImportForm(BulkImportForm, BootstrapMixin): class TenantBulkEditForm(forms.Form, BootstrapMixin): pk = forms.ModelMultipleChoiceField(queryset=Tenant.objects.all(), widget=forms.MultipleHiddenInput) - group = forms.ModelChoiceField(queryset=TenantGroup.objects.all(), required=False) + group = forms.TypedChoiceField(choices=bulkedit_tenantgroup_choices, coerce=int, required=False, label='Group') def tenant_group_choices(): diff --git a/netbox/tenancy/migrations/0002_tenant_group_optional.py b/netbox/tenancy/migrations/0002_tenant_group_optional.py new file mode 100644 index 000000000..95b1138ac --- /dev/null +++ b/netbox/tenancy/migrations/0002_tenant_group_optional.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.8 on 2016-08-02 19:54 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('tenancy', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='tenant', + name='group', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='tenants', to='tenancy.TenantGroup'), + ), + ] diff --git a/netbox/tenancy/models.py b/netbox/tenancy/models.py index 72bc92cae..6eb903f8b 100644 --- a/netbox/tenancy/models.py +++ b/netbox/tenancy/models.py @@ -28,7 +28,7 @@ class Tenant(CreatedUpdatedModel): """ name = models.CharField(max_length=30, unique=True) slug = models.SlugField(unique=True) - group = models.ForeignKey('TenantGroup', related_name='tenants', on_delete=models.PROTECT) + group = models.ForeignKey('TenantGroup', related_name='tenants', blank=True, null=True, on_delete=models.SET_NULL) description = models.CharField(max_length=100, blank=True, help_text="Long-form name (optional)") comments = models.TextField(blank=True) diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index bab624589..6b655ffd6 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -99,9 +99,10 @@ class TenantBulkEditView(PermissionRequiredMixin, BulkEditView): def update_objects(self, pk_list, form): fields_to_update = {} - for field in ['group']: - if form.cleaned_data[field]: - fields_to_update[field] = form.cleaned_data[field] + if form.cleaned_data['group'] == 0: + fields_to_update['group'] = None + elif form.cleaned_data['group']: + fields_to_update['group'] = form.cleaned_data['group'] return self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update) From 57373c9d6f3f9de5eb4ce9419cd3896e18f07b8c Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 2 Aug 2016 17:20:12 -0400 Subject: [PATCH 07/14] Initial work on #289 --- netbox/ipam/tables.py | 12 +++++++++++- netbox/ipam/views.py | 38 +++++++++++++++++++++++++++++++++++++- 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/netbox/ipam/tables.py b/netbox/ipam/tables.py index 8ac21e04c..800c0fb90 100644 --- a/netbox/ipam/tables.py +++ b/netbox/ipam/tables.py @@ -39,6 +39,16 @@ PREFIX_LINK_BRIEF = """ """ +IPADDRESS_LINK = """ +{% if record.pk %} + {{ record.address }} +{% elif perms.ipam.add_ipaddress %} + {{ record.0 }} free IP{{ record.0|pluralize }} +{% else %} + {{ record.0 }} +{% endif %} +""" + STATUS_LABEL = """ {% if record.pk %} {{ record.get_status_display }} @@ -169,7 +179,7 @@ class PrefixBriefTable(BaseTable): class IPAddressTable(BaseTable): pk = ToggleColumn() - address = tables.LinkColumn('ipam:ipaddress', args=[Accessor('pk')], verbose_name='IP Address') + address = tables.TemplateColumn(IPADDRESS_LINK, verbose_name='IP Address') vrf = tables.LinkColumn('ipam:vrf', args=[Accessor('vrf.pk')], default='Global', verbose_name='VRF') tenant = tables.TemplateColumn(TENANT_LINK, verbose_name='Tenant') device = tables.LinkColumn('dcim:device', args=[Accessor('interface.device.pk')], orderable=False, diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 95aa33b1e..86fc53c74 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -1,4 +1,4 @@ -from netaddr import IPSet +from netaddr import IPNetwork, IPSet from django_tables2 import RequestConfig from django.contrib.auth.mixins import PermissionRequiredMixin @@ -31,6 +31,41 @@ def add_available_prefixes(parent, prefix_list): return prefix_list +def add_available_ipaddresses(prefix, ipaddress_list): + """ + Create fake IPAddress objects for all unallocated space within a prefix. + """ + + # Find all unallocated space + available_ips = IPSet(prefix) - IPSet([str(ip.address.ip) for ip in ipaddress_list]) + available_ips = [IPAddress(address=IPNetwork('{}/{}'.format(ip, prefix.prefixlen))) for ip in available_ips] + + # Concatenate and sort complete list of children + ipaddress_list = list(ipaddress_list) + available_ips + ipaddress_list.sort(key=lambda ip: ip.address) + if not ipaddress_list: + return [] + + # Summarize free IPs in the list + computed_list = [] + count = 0 + prev_ip = ipaddress_list[0] + for ip in ipaddress_list: + if ip.pk: + if count: + computed_list.append((count, prev_ip)) + count = 0 + computed_list.append(ip) + continue + if not count: + prev_ip = ip + count += 1 + if count: + computed_list.append((count, prev_ip)) + + return computed_list + + # # VRFs # @@ -375,6 +410,7 @@ def prefix_ipaddresses(request, pk): # Find all IPAddresses belonging to this Prefix ipaddresses = IPAddress.objects.filter(vrf=prefix.vrf, address__net_contained_or_equal=str(prefix.prefix))\ .select_related('vrf', 'interface__device', 'primary_ip4_for', 'primary_ip6_for') + ipaddresses = add_available_ipaddresses(prefix.prefix, ipaddresses) ip_table = tables.IPAddressTable(ipaddresses) ip_table.model = IPAddress From 81d955ab7de3cccd000fbcbc5bcd60d78134ced2 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 3 Aug 2016 12:00:35 -0400 Subject: [PATCH 08/14] Rewrote add_available_ipaddresses() to be much more efficient and IPv6-friendly --- netbox/ipam/tables.py | 8 +++++- netbox/ipam/views.py | 65 ++++++++++++++++++++++++++----------------- 2 files changed, 46 insertions(+), 27 deletions(-) diff --git a/netbox/ipam/tables.py b/netbox/ipam/tables.py index 800c0fb90..4c5bbee3a 100644 --- a/netbox/ipam/tables.py +++ b/netbox/ipam/tables.py @@ -43,7 +43,7 @@ IPADDRESS_LINK = """ {% if record.pk %} {{ record.address }} {% elif perms.ipam.add_ipaddress %} - {{ record.0 }} free IP{{ record.0|pluralize }} + {% if record.0 <= 65536 %}{{ record.0 }}{% else %}Lots of{% endif %} free IP{{ record.0|pluralize }} {% else %} {{ record.0 }} {% endif %} @@ -158,6 +158,9 @@ class PrefixTable(BaseTable): class Meta(BaseTable.Meta): model = Prefix fields = ('pk', 'prefix', 'status', 'vrf', 'tenant', 'site', 'role', 'description') + row_attrs = { + 'class': lambda record: 'success' if not record.pk else '', + } class PrefixBriefTable(BaseTable): @@ -190,6 +193,9 @@ class IPAddressTable(BaseTable): class Meta(BaseTable.Meta): model = IPAddress fields = ('pk', 'address', 'vrf', 'tenant', 'device', 'interface', 'description') + row_attrs = { + 'class': lambda record: 'success' if not isinstance(record, IPAddress) else '', + } class IPAddressBriefTable(BaseTable): diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 86fc53c74..68cab7429 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -1,4 +1,4 @@ -from netaddr import IPNetwork, IPSet +import netaddr from django_tables2 import RequestConfig from django.contrib.auth.mixins import PermissionRequiredMixin @@ -21,7 +21,7 @@ def add_available_prefixes(parent, prefix_list): """ # Find all unallocated space - available_prefixes = IPSet(parent) ^ IPSet([p.prefix for p in prefix_list]) + available_prefixes = netaddr.IPSet(parent) ^ netaddr.IPSet([p.prefix for p in prefix_list]) available_prefixes = [Prefix(prefix=p) for p in available_prefixes.iter_cidrs()] # Concatenate and sort complete list of children @@ -33,37 +33,50 @@ def add_available_prefixes(parent, prefix_list): def add_available_ipaddresses(prefix, ipaddress_list): """ - Create fake IPAddress objects for all unallocated space within a prefix. + Annotate ranges of available IP addresses within a given prefix. """ - # Find all unallocated space - available_ips = IPSet(prefix) - IPSet([str(ip.address.ip) for ip in ipaddress_list]) - available_ips = [IPAddress(address=IPNetwork('{}/{}'.format(ip, prefix.prefixlen))) for ip in available_ips] + output = [] + prev_ip = None + + # Determine first and last usable IP + if prefix.version == 6 or (prefix.version == 4 and prefix.prefixlen == 31): + first_ip_in_prefix = netaddr.IPAddress(prefix.first) + else: + first_ip_in_prefix = netaddr.IPAddress(prefix.first + 1) + if prefix.version == 4 and prefix.prefixlen == 31: + last_ip_in_prefix = netaddr.IPAddress(prefix.last) + else: + last_ip_in_prefix = netaddr.IPAddress(prefix.last - 1) - # Concatenate and sort complete list of children - ipaddress_list = list(ipaddress_list) + available_ips - ipaddress_list.sort(key=lambda ip: ip.address) if not ipaddress_list: - return [] + return [( + int(last_ip_in_prefix - first_ip_in_prefix + 1), + '{}/{}'.format(first_ip_in_prefix, prefix.prefixlen) + )] - # Summarize free IPs in the list - computed_list = [] - count = 0 - prev_ip = ipaddress_list[0] + # Account for any available IPs before the first real IP + if ipaddress_list[0].address.ip != first_ip_in_prefix: + skipped_count = int(ipaddress_list[0].address.ip - first_ip_in_prefix) + first_skipped = '{}/{}'.format(first_ip_in_prefix, prefix.prefixlen) + output.append((skipped_count, first_skipped)) + + # Iterate through existing IPs and annotate free ranges for ip in ipaddress_list: - if ip.pk: - if count: - computed_list.append((count, prev_ip)) - count = 0 - computed_list.append(ip) - continue - if not count: - prev_ip = ip - count += 1 - if count: - computed_list.append((count, prev_ip)) + if prev_ip: + skipped_count = int(ip.address.ip - prev_ip.address.ip) + first_skipped = '{}/{}'.format(prev_ip.address.ip + 1, prefix.prefixlen) + output.append((skipped_count, first_skipped)) + output.append(ip) + prev_ip = ip - return computed_list + # Include any remaining available IPs + if prev_ip.address.ip != last_ip_in_prefix: + skipped_count = int(last_ip_in_prefix - prev_ip.address.ip) + first_skipped = '{}/{}'.format(prev_ip.address.ip + 1, prefix.prefixlen) + output.append((skipped_count, first_skipped)) + + return output # From 533b4082d804fab68223464627baa880b6c82204 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 3 Aug 2016 12:06:17 -0400 Subject: [PATCH 09/14] Fixed calculation of last_ip_in_prefix for IPv6 --- netbox/ipam/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 68cab7429..a9e84876e 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -44,7 +44,7 @@ def add_available_ipaddresses(prefix, ipaddress_list): first_ip_in_prefix = netaddr.IPAddress(prefix.first) else: first_ip_in_prefix = netaddr.IPAddress(prefix.first + 1) - if prefix.version == 4 and prefix.prefixlen == 31: + if prefix.version == 6 or (prefix.version == 4 and prefix.prefixlen == 31): last_ip_in_prefix = netaddr.IPAddress(prefix.last) else: last_ip_in_prefix = netaddr.IPAddress(prefix.last - 1) From 79b1bbb9e1f88328b240baf7c893339e3858cb46 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 3 Aug 2016 12:20:24 -0400 Subject: [PATCH 10/14] Fixed calculation of available IPs between two existing IPs --- netbox/ipam/views.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index a9e84876e..efed93989 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -64,9 +64,10 @@ def add_available_ipaddresses(prefix, ipaddress_list): # Iterate through existing IPs and annotate free ranges for ip in ipaddress_list: if prev_ip: - skipped_count = int(ip.address.ip - prev_ip.address.ip) - first_skipped = '{}/{}'.format(prev_ip.address.ip + 1, prefix.prefixlen) - output.append((skipped_count, first_skipped)) + skipped_count = int(ip.address.ip - prev_ip.address.ip - 1) + if skipped_count: + first_skipped = '{}/{}'.format(prev_ip.address.ip + 1, prefix.prefixlen) + output.append((skipped_count, first_skipped)) output.append(ip) prev_ip = ip From 3b9ac3b9863478cc0fa06ae147255008baf078e6 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 3 Aug 2016 12:30:29 -0400 Subject: [PATCH 11/14] More intelligent handling of first/last IPs --- netbox/ipam/views.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index efed93989..ce3c2a8f9 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -39,15 +39,17 @@ def add_available_ipaddresses(prefix, ipaddress_list): output = [] prev_ip = None - # Determine first and last usable IP - if prefix.version == 6 or (prefix.version == 4 and prefix.prefixlen == 31): - first_ip_in_prefix = netaddr.IPAddress(prefix.first) - else: + # Ignore the "network address" for IPv4 prefixes larger than /31 + if prefix.version == 4 and prefix.prefixlen < 31: first_ip_in_prefix = netaddr.IPAddress(prefix.first + 1) - if prefix.version == 6 or (prefix.version == 4 and prefix.prefixlen == 31): - last_ip_in_prefix = netaddr.IPAddress(prefix.last) else: + first_ip_in_prefix = netaddr.IPAddress(prefix.first) + + # Ignore the broadcast address for IPv4 prefixes larger than /31 + if prefix.version == 4 and prefix.prefixlen < 31: last_ip_in_prefix = netaddr.IPAddress(prefix.last - 1) + else: + last_ip_in_prefix = netaddr.IPAddress(prefix.last) if not ipaddress_list: return [( @@ -56,7 +58,7 @@ def add_available_ipaddresses(prefix, ipaddress_list): )] # Account for any available IPs before the first real IP - if ipaddress_list[0].address.ip != first_ip_in_prefix: + if ipaddress_list[0].address.ip > first_ip_in_prefix: skipped_count = int(ipaddress_list[0].address.ip - first_ip_in_prefix) first_skipped = '{}/{}'.format(first_ip_in_prefix, prefix.prefixlen) output.append((skipped_count, first_skipped)) @@ -72,7 +74,7 @@ def add_available_ipaddresses(prefix, ipaddress_list): prev_ip = ip # Include any remaining available IPs - if prev_ip.address.ip != last_ip_in_prefix: + if prev_ip.address.ip < last_ip_in_prefix: skipped_count = int(last_ip_in_prefix - prev_ip.address.ip) first_skipped = '{}/{}'.format(prev_ip.address.ip + 1, prefix.prefixlen) output.append((skipped_count, first_skipped)) From 72690bfd0a762f1be78cd7b7e98bd79516c763af Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 3 Aug 2016 14:24:09 -0400 Subject: [PATCH 12/14] Potential fix for #419: Ditch annotation in favor of discrete queries to gather Tenant stats --- netbox/templates/tenancy/tenant.html | 16 ++++++++-------- netbox/tenancy/views.py | 25 +++++++++++++++---------- 2 files changed, 23 insertions(+), 18 deletions(-) diff --git a/netbox/templates/tenancy/tenant.html b/netbox/templates/tenancy/tenant.html index 0d3d8fdaa..0ca380639 100644 --- a/netbox/templates/tenancy/tenant.html +++ b/netbox/templates/tenancy/tenant.html @@ -93,35 +93,35 @@
diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index 6b655ffd6..ec220b9be 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -2,6 +2,9 @@ from django.contrib.auth.mixins import PermissionRequiredMixin from django.db.models import Count from django.shortcuts import get_object_or_404, render +from circuits.models import Circuit +from dcim.models import Site, Rack, Device +from ipam.models import IPAddress, Prefix, VLAN, VRF from utilities.views import ( BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView, ) @@ -50,19 +53,21 @@ class TenantListView(ObjectListView): def tenant(request, slug): - tenant = get_object_or_404(Tenant.objects.annotate( - site_count=Count('sites', distinct=True), - rack_count=Count('racks', distinct=True), - device_count=Count('devices', distinct=True), - vrf_count=Count('vrfs', distinct=True), - prefix_count=Count('prefixes', distinct=True), - ipaddress_count=Count('ip_addresses', distinct=True), - vlan_count=Count('vlans', distinct=True), - circuit_count=Count('circuits', distinct=True), - ), slug=slug) + tenant = get_object_or_404(Tenant, slug=slug) + stats = { + 'site_count': Site.objects.filter(tenant=tenant).count(), + 'rack_count': Rack.objects.filter(tenant=tenant).count(), + 'device_count': Device.objects.filter(tenant=tenant).count(), + 'vrf_count': VRF.objects.filter(tenant=tenant).count(), + 'prefix_count': Prefix.objects.filter(tenant=tenant).count(), + 'ipaddress_count': IPAddress.objects.filter(tenant=tenant).count(), + 'vlan_count': VLAN.objects.filter(tenant=tenant).count(), + 'circuit_count': Circuit.objects.filter(tenant=tenant).count(), + } return render(request, 'tenancy/tenant.html', { 'tenant': tenant, + 'stats': stats, }) From 7944ee64193f37886f52a79f9f13bf3ef7733cfd Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 3 Aug 2016 17:33:15 -0400 Subject: [PATCH 13/14] Fixes #422: Added ability to encapsulate within double quotes values which contain commas --- netbox/utilities/forms.py | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index 3b4e93f10..836fe633f 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -1,3 +1,4 @@ +import csv import re from django import forms @@ -118,7 +119,8 @@ class Livesearch(forms.TextInput): class CSVDataField(forms.CharField): """ - A field for comma-separated values (CSV) + A field for comma-separated values (CSV). Values containing commas should be encased within double quotes. Example: + '"New York, NY",new-york-ny,Other stuff' => ['New York, NY', 'new-york-ny', 'Other stuff'] """ csv_form = None @@ -136,16 +138,16 @@ class CSVDataField(forms.CharField): def to_python(self, value): # Return a list of dictionaries, each representing an individual record records = [] - for i, row in enumerate(value.split('\n'), start=1): - if row.strip(): - values = row.strip().split(',') - if len(values) < len(self.columns): + reader = csv.reader(value.splitlines()) + for i, row in enumerate(reader, start=1): + if row: + if len(row) < len(self.columns): raise forms.ValidationError("Line {}: Field(s) missing (found {}; expected {})" - .format(i, len(values), len(self.columns))) - elif len(values) > len(self.columns): + .format(i, len(row), len(self.columns))) + elif len(row) > len(self.columns): raise forms.ValidationError("Line {}: Too many fields (found {}; expected {})" - .format(i, len(values), len(self.columns))) - record = dict(zip(self.columns, values)) + .format(i, len(row), len(self.columns))) + record = dict(zip(self.columns, row)) records.append(record) return records From 4ab40c4489a03568f5a8650ddb1489f70e14da48 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 3 Aug 2016 17:39:57 -0400 Subject: [PATCH 14/14] Release v1.4.1 --- netbox/netbox/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index e2a8ea3ef..4c5abcd56 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -12,7 +12,7 @@ except ImportError: "the documentation.") -VERSION = '1.4.1-dev' +VERSION = '1.4.1' # Import local configuration for setting in ['ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY']: