From 0708942ab85817af41e64f06e7dba9beb6b0ca87 Mon Sep 17 00:00:00 2001 From: Stian Vikan Date: Fri, 19 Aug 2016 12:09:40 +0200 Subject: [PATCH 01/23] Fixed csv reader to handle special characters --- netbox/utilities/forms.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index 4cbc1028b..c86496829 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -142,10 +142,15 @@ class CSVDataField(forms.CharField): if not self.help_text: self.help_text = 'Enter one line per record in CSV format.' + def utf_8_encoder(self, unicode_csv_data): + # convert csv,reader to utf-8e + for line in unicode_csv_data: + yield line.encode('utf-8') + def to_python(self, value): # Return a list of dictionaries, each representing an individual record records = [] - reader = csv.reader(value.splitlines()) + reader = csv.reader(self.utf_8_encoder(value.splitlines())) for i, row in enumerate(reader, start=1): if row: if len(row) < len(self.columns): From 4fa536b94067cf5cb38ebc2e2d74640af1f932af Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 13 Sep 2016 12:16:42 -0400 Subject: [PATCH 02/23] 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 8ce0a5c6a..bf63b272b 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -12,7 +12,7 @@ except ImportError: "the documentation.") -VERSION = '1.6.0' +VERSION = '1.6.1-dev' # Import local configuration for setting in ['ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY']: From 440610836a170074c378b5965b4d60de0c8a7854 Mon Sep 17 00:00:00 2001 From: Zach Moody Date: Tue, 13 Sep 2016 11:27:04 -0500 Subject: [PATCH 03/23] fixes permissions on docker-build.sh --- scripts/docker-build.sh | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 scripts/docker-build.sh diff --git a/scripts/docker-build.sh b/scripts/docker-build.sh old mode 100644 new mode 100755 From 9eec975800c1f5a210a3ec85bc402b72092a3da3 Mon Sep 17 00:00:00 2001 From: Robert Drake Date: Thu, 8 Sep 2016 19:47:48 -0400 Subject: [PATCH 04/23] change ldap.py to ldap_config.py --- 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 bf63b272b..e9c6ce232 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -71,7 +71,7 @@ if LDAP_CONFIGURED: logger.setLevel(logging.DEBUG) except ImportError: raise ImproperlyConfigured("LDAP authentication has been configured, but django-auth-ldap is not installed. " - "You can remove netbox/ldap.py to disable LDAP.") + "You can remove netbox/ldap_config.py to disable LDAP.") BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) From 9718895ff9cbe88b16abbe56049eaddf9979c75a Mon Sep 17 00:00:00 2001 From: Robert Drake Date: Thu, 8 Sep 2016 20:05:32 -0400 Subject: [PATCH 05/23] add django-auth-ldap to Dockerfile --- Dockerfile | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 2cf8b9294..63562e2ea 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,7 +5,10 @@ WORKDIR /opt/netbox ARG BRANCH=master ARG URL=https://github.com/digitalocean/netbox.git RUN git clone --depth 1 $URL -b $BRANCH . && \ - pip install gunicorn==17.5 && pip install -r requirements.txt + apt-get update -qq && apt-get install -y libldap2-dev libsasl2-dev libssl-dev && \ + pip install gunicorn==17.5 && \ + pip install django-auth-ldap && \ + pip install -r requirements.txt ADD docker/docker-entrypoint.sh /docker-entrypoint.sh ADD netbox/netbox/configuration.docker.py /opt/netbox/netbox/netbox/configuration.py From 824d2d8205781e0dafa038cad9ac19865c4b7a1f Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 14 Sep 2016 16:27:26 -0400 Subject: [PATCH 06/23] Implemented FilterChoiceField and get_filter_choices() to reduce filter form boilerplate --- netbox/circuits/forms.py | 43 +++----------- netbox/dcim/forms.py | 114 ++++++++------------------------------ netbox/ipam/forms.py | 112 ++++++++----------------------------- netbox/secrets/forms.py | 12 ++-- netbox/tenancy/forms.py | 13 ++--- netbox/utilities/forms.py | 32 +++++++++++ 6 files changed, 91 insertions(+), 235 deletions(-) diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py index 0cb7a3016..c934ab630 100644 --- a/netbox/circuits/forms.py +++ b/netbox/circuits/forms.py @@ -1,12 +1,12 @@ from django import forms -from django.db.models import Count from dcim.models import Site, Device, Interface, Rack, IFACE_FF_VIRTUAL from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm from tenancy.forms import bulkedit_tenant_choices from tenancy.models import Tenant from utilities.forms import ( - APISelect, BootstrapMixin, BulkImportForm, CommentField, CSVDataField, Livesearch, SmallTextarea, SlugField, + APISelect, BootstrapMixin, BulkImportForm, CommentField, CSVDataField, FilterChoiceField, Livesearch, SmallTextarea, + SlugField, get_filter_choices, ) from .models import Circuit, CircuitType, Provider @@ -57,15 +57,9 @@ class ProviderBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): comments = CommentField() -def provider_site_choices(): - site_choices = Site.objects.all() - return [(s.slug, s.name) for s in site_choices] - - class ProviderFilterForm(BootstrapMixin, CustomFieldFilterForm): model = Provider - site = forms.MultipleChoiceField(required=False, choices=provider_site_choices, - widget=forms.SelectMultiple(attrs={'size': 8})) + site = FilterChoiceField(choices=get_filter_choices(Site, id_field='slug')) # @@ -189,32 +183,9 @@ class CircuitBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): comments = CommentField() -def circuit_type_choices(): - type_choices = CircuitType.objects.annotate(circuit_count=Count('circuits')) - return [(t.slug, u'{} ({})'.format(t.name, t.circuit_count)) for t in type_choices] - - -def circuit_provider_choices(): - provider_choices = Provider.objects.annotate(circuit_count=Count('circuits')) - return [(p.slug, u'{} ({})'.format(p.name, p.circuit_count)) for p in provider_choices] - - -def circuit_tenant_choices(): - tenant_choices = Tenant.objects.annotate(circuit_count=Count('circuits')) - return [(t.slug, u'{} ({})'.format(t.name, t.circuit_count)) for t in tenant_choices] - - -def circuit_site_choices(): - site_choices = Site.objects.annotate(circuit_count=Count('circuits')) - return [(s.slug, u'{} ({})'.format(s.name, s.circuit_count)) for s in site_choices] - - class CircuitFilterForm(BootstrapMixin, CustomFieldFilterForm): model = Circuit - type = forms.MultipleChoiceField(required=False, choices=circuit_type_choices) - provider = forms.MultipleChoiceField(required=False, choices=circuit_provider_choices, - widget=forms.SelectMultiple(attrs={'size': 8})) - tenant = forms.MultipleChoiceField(required=False, choices=circuit_tenant_choices, - widget=forms.SelectMultiple(attrs={'size': 8})) - site = forms.MultipleChoiceField(required=False, choices=circuit_site_choices, - widget=forms.SelectMultiple(attrs={'size': 8})) + type = FilterChoiceField(choices=get_filter_choices(CircuitType, id_field='slug', count_field='circuits')) + provider = FilterChoiceField(choices=get_filter_choices(Provider, id_field='slug', count_field='circuits')) + tenant = FilterChoiceField(choices=get_filter_choices(Tenant, id_field='slug', count_field='circuits')) + site = FilterChoiceField(choices=get_filter_choices(Site, id_field='slug', count_field='circuits')) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 8d2e48430..7625955b4 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -1,7 +1,7 @@ import re from django import forms -from django.db.models import Count, Q +from django.db.models import Q from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm from ipam.models import IPAddress @@ -9,7 +9,8 @@ from tenancy.forms import bulkedit_tenant_choices from tenancy.models import Tenant from utilities.forms import ( APISelect, add_blank_choice, BootstrapMixin, BulkImportForm, CommentField, CSVDataField, ExpandableNameField, - FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SmallTextarea, SlugField, + FilterChoiceField, FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SmallTextarea, SlugField, + get_filter_choices ) from .models import ( @@ -117,15 +118,9 @@ class SiteBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): tenant = forms.TypedChoiceField(choices=bulkedit_tenant_choices, coerce=int, required=False, label='Tenant') -def site_tenant_choices(): - tenant_choices = Tenant.objects.annotate(site_count=Count('sites')) - return [(t.slug, u'{} ({})'.format(t.name, t.site_count)) for t in tenant_choices] - - class SiteFilterForm(BootstrapMixin, CustomFieldFilterForm): model = Site - tenant = forms.MultipleChoiceField(required=False, choices=site_tenant_choices, - widget=forms.SelectMultiple(attrs={'size': 8})) + tenant = FilterChoiceField(choices=get_filter_choices(Tenant, id_field='slug', count_field='sites')) # @@ -140,14 +135,8 @@ class RackGroupForm(forms.ModelForm, BootstrapMixin): fields = ['site', 'name', 'slug'] -def rackgroup_site_choices(): - site_choices = Site.objects.annotate(rack_count=Count('rack_groups')) - return [(s.slug, u'{} ({})'.format(s.name, s.rack_count)) for s in site_choices] - - class RackGroupFilterForm(forms.Form, BootstrapMixin): - site = forms.MultipleChoiceField(required=False, choices=rackgroup_site_choices, - widget=forms.SelectMultiple(attrs={'size': 8})) + site = FilterChoiceField(choices=get_filter_choices(Site, id_field='slug', count_field='rack_groups')) # @@ -254,36 +243,13 @@ class RackBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): comments = CommentField() -def rack_site_choices(): - site_choices = Site.objects.annotate(rack_count=Count('racks')) - return [(s.slug, u'{} ({})'.format(s.name, s.rack_count)) for s in site_choices] - - -def rack_group_choices(): - group_choices = RackGroup.objects.select_related('site').annotate(rack_count=Count('racks')) - return [(g.pk, u'{} ({})'.format(g, g.rack_count)) for g in group_choices] - - -def rack_tenant_choices(): - tenant_choices = Tenant.objects.annotate(rack_count=Count('racks')) - return [(t.slug, u'{} ({})'.format(t.name, t.rack_count)) for t in tenant_choices] - - -def rack_role_choices(): - role_choices = RackRole.objects.annotate(rack_count=Count('racks')) - return [(r.slug, u'{} ({})'.format(r.name, r.rack_count)) for r in role_choices] - - class RackFilterForm(BootstrapMixin, CustomFieldFilterForm): model = Rack - site = forms.MultipleChoiceField(required=False, choices=rack_site_choices, - widget=forms.SelectMultiple(attrs={'size': 8})) - group_id = forms.MultipleChoiceField(required=False, choices=rack_group_choices, label='Rack Group', - widget=forms.SelectMultiple(attrs={'size': 8})) - tenant = forms.MultipleChoiceField(required=False, choices=rack_tenant_choices, - widget=forms.SelectMultiple(attrs={'size': 8})) - role = forms.MultipleChoiceField(required=False, choices=rack_role_choices, - widget=forms.SelectMultiple(attrs={'size': 8})) + site = FilterChoiceField(choices=get_filter_choices(Site, id_field='slug', count_field='racks')) + group_id = FilterChoiceField(choices=get_filter_choices(RackGroup, select_related=['site'], count_field='racks'), + label='Rack Group') + tenant = FilterChoiceField(choices=get_filter_choices(Tenant, id_field='slug', count_field='racks')) + role = FilterChoiceField(choices=get_filter_choices(RackRole, id_field='slug', count_field='racks')) # @@ -317,14 +283,9 @@ class DeviceTypeBulkEditForm(forms.Form, BootstrapMixin): u_height = forms.IntegerField(min_value=1, required=False) -def devicetype_manufacturer_choices(): - manufacturer_choices = Manufacturer.objects.annotate(devicetype_count=Count('device_types')) - return [(m.slug, u'{} ({})'.format(m.name, m.devicetype_count)) for m in manufacturer_choices] - - class DeviceTypeFilterForm(forms.Form, BootstrapMixin): - manufacturer = forms.MultipleChoiceField(required=False, choices=devicetype_manufacturer_choices, - widget=forms.SelectMultiple(attrs={'size': 8})) + manufacturer = FilterChoiceField(choices=get_filter_choices(Manufacturer, id_field='slug', + count_field='device_types')) # @@ -627,49 +588,18 @@ class DeviceBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): serial = forms.CharField(max_length=50, required=False, label='Serial Number') -def device_site_choices(): - site_choices = Site.objects.annotate(device_count=Count('racks__devices')) - return [(s.slug, u'{} ({})'.format(s.name, s.device_count)) for s in site_choices] - - -def device_rack_group_choices(): - group_choices = RackGroup.objects.select_related('site').annotate(device_count=Count('racks__devices')) - return [(g.pk, u'{} ({})'.format(g, g.device_count)) for g in group_choices] - - -def device_role_choices(): - role_choices = DeviceRole.objects.annotate(device_count=Count('devices')) - return [(r.slug, u'{} ({})'.format(r.name, r.device_count)) for r in role_choices] - - -def device_tenant_choices(): - tenant_choices = Tenant.objects.annotate(device_count=Count('devices')) - return [(t.slug, u'{} ({})'.format(t.name, t.device_count)) for t in tenant_choices] - - -def device_type_choices(): - type_choices = DeviceType.objects.select_related('manufacturer').annotate(device_count=Count('instances')) - return [(t.pk, u'{} ({})'.format(t, t.device_count)) for t in type_choices] - - -def device_platform_choices(): - platform_choices = Platform.objects.annotate(device_count=Count('devices')) - return [(p.slug, u'{} ({})'.format(p.name, p.device_count)) for p in platform_choices] - - class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm): model = Device - site = forms.MultipleChoiceField(required=False, choices=device_site_choices, - widget=forms.SelectMultiple(attrs={'size': 8})) - rack_group_id = forms.MultipleChoiceField(required=False, choices=device_rack_group_choices, label='Rack Group', - widget=forms.SelectMultiple(attrs={'size': 8})) - role = forms.MultipleChoiceField(required=False, choices=device_role_choices, - widget=forms.SelectMultiple(attrs={'size': 8})) - tenant = forms.MultipleChoiceField(required=False, choices=device_tenant_choices, - widget=forms.SelectMultiple(attrs={'size': 8})) - device_type_id = forms.MultipleChoiceField(required=False, choices=device_type_choices, label='Type', - widget=forms.SelectMultiple(attrs={'size': 8})) - platform = forms.MultipleChoiceField(required=False, choices=device_platform_choices) + site = FilterChoiceField(choices=get_filter_choices(Site, id_field='slug', count_field='racks__devices')) + rack_group_id = FilterChoiceField(choices=get_filter_choices(RackGroup, select_related=['site'], + count_field='racks__devices'), + label='Rack Group') + role = FilterChoiceField(choices=get_filter_choices(DeviceRole, id_field='slug', count_field='devices')) + tenant = FilterChoiceField(choices=get_filter_choices(Tenant, id_field='slug', count_field='devices')) + device_type_id = FilterChoiceField(choices=get_filter_choices(DeviceType, select_related=['manufacturer'], + count_field='instances'), + label='Type') + platform = FilterChoiceField(choices=get_filter_choices(Platform, id_field='slug', count_field='devices')) status = forms.NullBooleanField(required=False, widget=forms.Select(choices=FORM_STATUS_CHOICES)) diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 5e08ca854..073c06f27 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -5,7 +5,10 @@ from dcim.models import Site, Device, Interface from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm from tenancy.forms import bulkedit_tenant_choices from tenancy.models import Tenant -from utilities.forms import BootstrapMixin, APISelect, Livesearch, CSVDataField, BulkImportForm, SlugField +from utilities.forms import ( + APISelect, BootstrapMixin, CSVDataField, BulkImportForm, FilterChoiceField, Livesearch, SlugField, + get_filter_choices, +) from .models import ( Aggregate, IPAddress, Prefix, PREFIX_STATUS_CHOICES, RIR, Role, VLAN, VLANGroup, VLAN_STATUS_CHOICES, VRF, @@ -69,15 +72,9 @@ class VRFBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): description = forms.CharField(max_length=100, required=False) -def vrf_tenant_choices(): - tenant_choices = Tenant.objects.annotate(vrf_count=Count('vrfs')) - return [(t.slug, u'{} ({})'.format(t.name, t.vrf_count)) for t in tenant_choices] - - class VRFFilterForm(BootstrapMixin, CustomFieldFilterForm): model = VRF - tenant = forms.MultipleChoiceField(required=False, choices=vrf_tenant_choices, - widget=forms.SelectMultiple(attrs={'size': 8})) + tenant = FilterChoiceField(choices=get_filter_choices(Tenant, id_field='slug', count_field='vrfs')) # @@ -128,16 +125,10 @@ class AggregateBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): description = forms.CharField(max_length=100, required=False) -def aggregate_rir_choices(): - rir_choices = RIR.objects.annotate(aggregate_count=Count('aggregates')) - return [(r.slug, u'{} ({})'.format(r.name, r.aggregate_count)) for r in rir_choices] - - class AggregateFilterForm(BootstrapMixin, CustomFieldFilterForm): model = Aggregate family = forms.ChoiceField(required=False, choices=IP_FAMILY_CHOICES, label='Address Family') - rir = forms.MultipleChoiceField(required=False, choices=aggregate_rir_choices, label='RIR', - widget=forms.SelectMultiple(attrs={'size': 8})) + rir = FilterChoiceField(choices=get_filter_choices(RIR, id_field='slug', count_field='aggregates'), label='RIR') # @@ -268,21 +259,6 @@ class PrefixBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): description = forms.CharField(max_length=100, required=False) -def prefix_vrf_choices(): - vrf_choices = VRF.objects.annotate(prefix_count=Count('prefixes')) - 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] - - def prefix_status_choices(): status_counts = {} for status in Prefix.objects.values('status').annotate(count=Count('status')).order_by('status'): @@ -290,27 +266,18 @@ def prefix_status_choices(): return [(s[0], u'{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in PREFIX_STATUS_CHOICES] -def prefix_role_choices(): - role_choices = Role.objects.annotate(prefix_count=Count('prefixes')) - return [(r.slug, u'{} ({})'.format(r.name, r.prefix_count)) for r in role_choices] - - class PrefixFilterForm(BootstrapMixin, CustomFieldFilterForm): model = Prefix parent = forms.CharField(required=False, label='Search Within', widget=forms.TextInput(attrs={ 'placeholder': 'Network', })) family = forms.ChoiceField(required=False, choices=IP_FAMILY_CHOICES, label='Address Family') - vrf = forms.MultipleChoiceField(required=False, choices=prefix_vrf_choices, label='VRF', - 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, - widget=forms.SelectMultiple(attrs={'size': 6})) - role = forms.MultipleChoiceField(required=False, choices=prefix_role_choices, - widget=forms.SelectMultiple(attrs={'size': 6})) + vrf = FilterChoiceField(choices=get_filter_choices(VRF, count_field='prefixes'), label='VRF') + tenant = FilterChoiceField(choices=get_filter_choices(Tenant, id_field='slug', count_field='prefixes'), + label='Tenant') + status = FilterChoiceField(choices=prefix_status_choices) + site = FilterChoiceField(choices=get_filter_choices(Site, id_field='slug', count_field='prefixes')) + role = FilterChoiceField(choices=get_filter_choices(Role, id_field='slug', count_field='prefixes')) expand = forms.BooleanField(required=False, label='Expand prefix hierarchy') @@ -441,21 +408,15 @@ class IPAddressBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): description = forms.CharField(max_length=100, required=False) -def ipaddress_vrf_choices(): - vrf_choices = VRF.objects.annotate(ipaddress_count=Count('ip_addresses')) - return [(v.pk, u'{} ({})'.format(v.name, v.ipaddress_count)) for v in vrf_choices] - - class IPAddressFilterForm(BootstrapMixin, CustomFieldFilterForm): model = IPAddress parent = forms.CharField(required=False, label='Search Within', widget=forms.TextInput(attrs={ 'placeholder': 'Prefix', })) family = forms.ChoiceField(required=False, choices=IP_FAMILY_CHOICES, label='Address Family') - vrf = forms.MultipleChoiceField(required=False, choices=ipaddress_vrf_choices, label='VRF', - widget=forms.SelectMultiple(attrs={'size': 6})) - tenant = forms.MultipleChoiceField(required=False, choices=tenant_choices, label='Tenant', - widget=forms.SelectMultiple(attrs={'size': 6})) + vrf = FilterChoiceField(choices=get_filter_choices(VRF, count_field='ip_addresses'), label='VRF') + tenant = FilterChoiceField(choices=get_filter_choices(Tenant, id_field='slug', count_field='prefixes'), + label='Tenant') # @@ -470,14 +431,8 @@ class VLANGroupForm(forms.ModelForm, BootstrapMixin): fields = ['site', 'name', 'slug'] -def vlangroup_site_choices(): - site_choices = Site.objects.annotate(vlangroup_count=Count('vlan_groups')) - return [(s.slug, u'{} ({})'.format(s.name, s.vlangroup_count)) for s in site_choices] - - class VLANGroupFilterForm(forms.Form, BootstrapMixin): - site = forms.MultipleChoiceField(required=False, choices=vlangroup_site_choices, - widget=forms.SelectMultiple(attrs={'size': 8})) + site = FilterChoiceField(choices=get_filter_choices(Site, id_field='slug', count_field='vlan_groups')) # @@ -555,21 +510,6 @@ class VLANBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): description = forms.CharField(max_length=100, required=False) -def vlan_site_choices(): - site_choices = Site.objects.annotate(vlan_count=Count('vlans')) - return [(s.slug, u'{} ({})'.format(s.name, s.vlan_count)) for s in site_choices] - - -def vlan_group_choices(): - group_choices = VLANGroup.objects.select_related('site').annotate(vlan_count=Count('vlans')) - return [(g.pk, u'{} ({})'.format(g, g.vlan_count)) for g in group_choices] - - -def vlan_tenant_choices(): - tenant_choices = Tenant.objects.annotate(vrf_count=Count('vlans')) - return [(t.slug, u'{} ({})'.format(t.name, t.vrf_count)) for t in tenant_choices] - - def vlan_status_choices(): status_counts = {} for status in VLAN.objects.values('status').annotate(count=Count('status')).order_by('status'): @@ -577,19 +517,11 @@ def vlan_status_choices(): return [(s[0], u'{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in VLAN_STATUS_CHOICES] -def vlan_role_choices(): - role_choices = Role.objects.annotate(vlan_count=Count('vlans')) - return [(r.slug, u'{} ({})'.format(r.name, r.vlan_count)) for r in role_choices] - - class VLANFilterForm(BootstrapMixin, CustomFieldFilterForm): model = VLAN - site = forms.MultipleChoiceField(required=False, choices=vlan_site_choices, - widget=forms.SelectMultiple(attrs={'size': 8})) - group_id = forms.MultipleChoiceField(required=False, choices=vlan_group_choices, label='VLAN Group', - widget=forms.SelectMultiple(attrs={'size': 8})) - tenant = forms.MultipleChoiceField(required=False, choices=vlan_tenant_choices, - widget=forms.SelectMultiple(attrs={'size': 8})) - status = forms.MultipleChoiceField(required=False, choices=vlan_status_choices) - role = forms.MultipleChoiceField(required=False, choices=vlan_role_choices, - widget=forms.SelectMultiple(attrs={'size': 8})) + site = FilterChoiceField(choices=get_filter_choices(Site, id_field='slug', count_field='vlans')) + group_id = FilterChoiceField(choices=get_filter_choices(VLANGroup, select_related=['site'], count_field='vlans'), + label='VLAN Group') + tenant = FilterChoiceField(choices=get_filter_choices(Tenant, id_field='slug', count_field='vlans')) + status = FilterChoiceField(choices=vlan_status_choices) + role = FilterChoiceField(choices=get_filter_choices(Role, id_field='slug', count_field='vlans')) diff --git a/netbox/secrets/forms.py b/netbox/secrets/forms.py index 1e45fe163..e3ebb7500 100644 --- a/netbox/secrets/forms.py +++ b/netbox/secrets/forms.py @@ -2,10 +2,11 @@ from Crypto.Cipher import PKCS1_OAEP from Crypto.PublicKey import RSA from django import forms -from django.db.models import Count from dcim.models import Device -from utilities.forms import BootstrapMixin, BulkImportForm, CSVDataField, SlugField +from utilities.forms import ( + BootstrapMixin, BulkImportForm, CSVDataField, FilterChoiceField, SlugField, get_filter_choices, +) from .models import Secret, SecretRole, UserKey @@ -95,13 +96,8 @@ class SecretBulkEditForm(forms.Form, BootstrapMixin): name = forms.CharField(max_length=100, required=False) -def secret_role_choices(): - role_choices = SecretRole.objects.annotate(secret_count=Count('secrets')) - return [(r.slug, u'{} ({})'.format(r.name, r.secret_count)) for r in role_choices] - - class SecretFilterForm(forms.Form, BootstrapMixin): - role = forms.MultipleChoiceField(required=False, choices=secret_role_choices) + role = FilterChoiceField(choices=get_filter_choices(SecretRole, id_field='slug', count_field='secrets')) # diff --git a/netbox/tenancy/forms.py b/netbox/tenancy/forms.py index 40b5b5b1d..8bbd05372 100644 --- a/netbox/tenancy/forms.py +++ b/netbox/tenancy/forms.py @@ -1,8 +1,9 @@ from django import forms -from django.db.models import Count from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm -from utilities.forms import BootstrapMixin, BulkImportForm, CommentField, CSVDataField, SlugField +from utilities.forms import ( + BootstrapMixin, BulkImportForm, CommentField, CSVDataField, FilterChoiceField, SlugField, get_filter_choices, +) from .models import Tenant, TenantGroup @@ -74,12 +75,6 @@ class TenantBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): group = forms.TypedChoiceField(choices=bulkedit_tenantgroup_choices, coerce=int, required=False, label='Group') -def tenant_group_choices(): - group_choices = TenantGroup.objects.annotate(tenant_count=Count('tenants')) - return [(g.slug, u'{} ({})'.format(g.name, g.tenant_count)) for g in group_choices] - - class TenantFilterForm(BootstrapMixin, CustomFieldFilterForm): model = Tenant - group = forms.MultipleChoiceField(required=False, choices=tenant_group_choices, - widget=forms.SelectMultiple(attrs={'size': 8})) + group = FilterChoiceField(choices=get_filter_choices(TenantGroup, id_field='slug', count_field='tenants')) diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index 4cbc1028b..30989ddcf 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -3,6 +3,7 @@ import re from django import forms from django.core.urlresolvers import reverse_lazy +from django.db.models import Count from django.utils.encoding import force_text from django.utils.html import format_html from django.utils.safestring import mark_safe @@ -34,6 +35,27 @@ def add_blank_choice(choices): return ((None, '---------'),) + choices +def get_filter_choices(model, id_field='pk', select_related=[], count_field=None): + """ + Return a list of choices suitable for a ChoiceField. + + :param model: The base model to use for the queryset + :param id_field: Field to use as the object identifier + :param select_related: Any related tables to include + :param count: The field to use for a child COUNT() (optional) + :return: + """ + queryset = model.objects.all() + if select_related: + queryset = queryset.select_related(*select_related) + if count_field: + queryset = queryset.annotate(child_count=Count(count_field)) + return [(getattr(obj, id_field), u'{} ({})'.format(obj, obj.child_count)) for obj in queryset] + else: + return [(getattr(obj, id_field), u'{}'.format(obj)) for obj in queryset] + + + # # Widgets # @@ -222,6 +244,16 @@ class SlugField(forms.SlugField): self.widget.attrs['slug-source'] = slug_source +class FilterChoiceField(forms.MultipleChoiceField): + + def __init__(self, *args, **kwargs): + if 'required' not in kwargs: + kwargs['required'] = False + if 'widget' not in kwargs: + kwargs['widget'] = forms.SelectMultiple(attrs={'size': 6}) + super(FilterChoiceField, self).__init__(*args, **kwargs) + + # # Forms # From 5e4fce248c7a7fe8b4e2d4475332c465ea3a71de Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 15 Sep 2016 11:36:45 -0400 Subject: [PATCH 07/23] Fixes #558: Update slug field when name is populated without a key press --- netbox/project-static/js/forms.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/project-static/js/forms.js b/netbox/project-static/js/forms.js index 4f0592b34..6647046d7 100644 --- a/netbox/project-static/js/forms.js +++ b/netbox/project-static/js/forms.js @@ -25,7 +25,7 @@ $(document).ready(function() { }); if (slug_field) { var slug_source = $('#id_' + slug_field.attr('slug-source')); - slug_source.keyup(function() { + slug_source.on('keyup change', function() { if (slug_field && !slug_field.attr('_changed')) { slug_field.val(slugify($(this).val(), 50)); } From 2567412121e4fda06bc17652381f20ab942fd41b Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 15 Sep 2016 12:09:54 -0400 Subject: [PATCH 08/23] Fixes #531: Order prefixes by VRF assignment --- .../migrations/0008_prefix_change_order.py | 19 +++++++++++ netbox/ipam/models.py | 5 +-- netbox/utilities/sql.py | 32 +++++++++++++++++++ 3 files changed, 54 insertions(+), 2 deletions(-) create mode 100644 netbox/ipam/migrations/0008_prefix_change_order.py create mode 100644 netbox/utilities/sql.py diff --git a/netbox/ipam/migrations/0008_prefix_change_order.py b/netbox/ipam/migrations/0008_prefix_change_order.py new file mode 100644 index 000000000..3ad3eb9e3 --- /dev/null +++ b/netbox/ipam/migrations/0008_prefix_change_order.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10 on 2016-09-15 16:08 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('ipam', '0007_prefix_ipaddress_add_tenant'), + ] + + operations = [ + migrations.AlterModelOptions( + name='prefix', + options={'ordering': ['vrf', 'family', 'prefix'], 'verbose_name_plural': 'prefixes'}, + ), + ] diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index 0b8b09049..c602fa3e0 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -12,6 +12,7 @@ from dcim.models import Interface from extras.models import CustomFieldModel, CustomFieldValue from tenancy.models import Tenant from utilities.models import CreatedUpdatedModel +from utilities.sql import NullsFirstQuerySet from .fields import IPNetworkField, IPAddressField @@ -192,7 +193,7 @@ class Role(models.Model): return self.vlans.count() -class PrefixQuerySet(models.QuerySet): +class PrefixQuerySet(NullsFirstQuerySet): def annotate_depth(self, limit=None): """ @@ -249,7 +250,7 @@ class Prefix(CreatedUpdatedModel, CustomFieldModel): objects = PrefixQuerySet.as_manager() class Meta: - ordering = ['family', 'prefix'] + ordering = ['vrf', 'family', 'prefix'] verbose_name_plural = 'prefixes' def __unicode__(self): diff --git a/netbox/utilities/sql.py b/netbox/utilities/sql.py new file mode 100644 index 000000000..617586ab8 --- /dev/null +++ b/netbox/utilities/sql.py @@ -0,0 +1,32 @@ +from django.db import connections, models +from django.db.models.sql.compiler import SQLCompiler + + +class NullsFirstSQLCompiler(SQLCompiler): + + def get_order_by(self): + result = super(NullsFirstSQLCompiler, self).get_order_by() + if result: + return [(expr, (sql + ' NULLS FIRST', params, is_ref)) for (expr, (sql, params, is_ref)) in result] + return result + + +class NullsFirstQuery(models.sql.query.Query): + + def get_compiler(self, using=None, connection=None): + if using is None and connection is None: + raise ValueError("Need either using or connection") + if using: + connection = connections[using] + return NullsFirstSQLCompiler(self, connection, using) + + +class NullsFirstQuerySet(models.QuerySet): + """ + Override PostgreSQL's default behavior of ordering NULLs last. This is needed e.g. to order Prefixes in the global + table before those assigned to a VRF. + """ + + def __init__(self, model=None, query=None, using=None, hints=None): + super(NullsFirstQuerySet, self).__init__(model, query, using, hints) + self.query = query or NullsFirstQuery(self.model) From daadf7a49b3d5f2f70feb09881847f10825f7f41 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 15 Sep 2016 16:03:53 -0400 Subject: [PATCH 09/23] Fixes #557: Add 'global' choice to VRF filter for prefixes and IP addresses --- netbox/ipam/forms.py | 3 ++- netbox/utilities/forms.py | 13 ++++++++----- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 073c06f27..45fed46f2 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -272,7 +272,8 @@ class PrefixFilterForm(BootstrapMixin, CustomFieldFilterForm): 'placeholder': 'Network', })) family = forms.ChoiceField(required=False, choices=IP_FAMILY_CHOICES, label='Address Family') - vrf = FilterChoiceField(choices=get_filter_choices(VRF, count_field='prefixes'), label='VRF') + vrf = FilterChoiceField(choices=get_filter_choices(VRF, count_field='prefixes', null_option=(0, 'Global')), + label='VRF') tenant = FilterChoiceField(choices=get_filter_choices(Tenant, id_field='slug', count_field='prefixes'), label='Tenant') status = FilterChoiceField(choices=prefix_status_choices) diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index 30989ddcf..ed1ce1533 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -35,24 +35,27 @@ def add_blank_choice(choices): return ((None, '---------'),) + choices -def get_filter_choices(model, id_field='pk', select_related=[], count_field=None): +def get_filter_choices(model, id_field='pk', select_related=[], count_field=None, null_option=None): """ Return a list of choices suitable for a ChoiceField. :param model: The base model to use for the queryset :param id_field: Field to use as the object identifier :param select_related: Any related tables to include - :param count: The field to use for a child COUNT() (optional) - :return: + :param count_field: The field to use for a child COUNT() (optional) + :param null_option: A (value, label) tuple to include at the beginning of the list serving as "null" """ queryset = model.objects.all() if select_related: queryset = queryset.select_related(*select_related) if count_field: queryset = queryset.annotate(child_count=Count(count_field)) - return [(getattr(obj, id_field), u'{} ({})'.format(obj, obj.child_count)) for obj in queryset] + choices = [(getattr(obj, id_field), u'{} ({})'.format(obj, obj.child_count)) for obj in queryset] else: - return [(getattr(obj, id_field), u'{}'.format(obj)) for obj in queryset] + choices = [(getattr(obj, id_field), u'{}'.format(obj)) for obj in queryset] + if null_option: + choices = [null_option] + choices + return choices From 9dea5656ad5bcee21dfa599d85843eb89166d3ba Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 15 Sep 2016 17:12:53 -0400 Subject: [PATCH 10/23] Added 'none' options to filters for optional fields --- netbox/circuits/filters.py | 6 ++- netbox/circuits/forms.py | 3 +- netbox/dcim/filters.py | 25 +++++------ netbox/dcim/forms.py | 18 +++++--- netbox/ipam/filters.py | 86 ++++++++++++++++++------------------- netbox/ipam/forms.py | 28 +++++++----- netbox/tenancy/filters.py | 5 ++- netbox/tenancy/forms.py | 3 +- netbox/utilities/filters.py | 43 +++++++++++++++++++ netbox/utilities/forms.py | 4 +- 10 files changed, 142 insertions(+), 79 deletions(-) create mode 100644 netbox/utilities/filters.py diff --git a/netbox/circuits/filters.py b/netbox/circuits/filters.py index f719290ed..152588c5a 100644 --- a/netbox/circuits/filters.py +++ b/netbox/circuits/filters.py @@ -5,6 +5,8 @@ from django.db.models import Q from dcim.models import Site from extras.filters import CustomFieldFilterSet from tenancy.models import Tenant +from utilities.filters import NullableModelMultipleChoiceFilter + from .models import Provider, Circuit, CircuitType @@ -64,12 +66,12 @@ class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet): to_field_name='slug', label='Circuit type (slug)', ) - tenant_id = django_filters.ModelMultipleChoiceFilter( + tenant_id = NullableModelMultipleChoiceFilter( name='tenant', queryset=Tenant.objects.all(), label='Tenant (ID)', ) - tenant = django_filters.ModelMultipleChoiceFilter( + tenant = NullableModelMultipleChoiceFilter( name='tenant', queryset=Tenant.objects.all(), to_field_name='slug', diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py index c934ab630..233741be8 100644 --- a/netbox/circuits/forms.py +++ b/netbox/circuits/forms.py @@ -187,5 +187,6 @@ class CircuitFilterForm(BootstrapMixin, CustomFieldFilterForm): model = Circuit type = FilterChoiceField(choices=get_filter_choices(CircuitType, id_field='slug', count_field='circuits')) provider = FilterChoiceField(choices=get_filter_choices(Provider, id_field='slug', count_field='circuits')) - tenant = FilterChoiceField(choices=get_filter_choices(Tenant, id_field='slug', count_field='circuits')) + tenant = FilterChoiceField(choices=get_filter_choices(Tenant, id_field='slug', count_field='circuits', + null_option='None')) site = FilterChoiceField(choices=get_filter_choices(Site, id_field='slug', count_field='circuits')) diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index eac47445e..65831a974 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -4,6 +4,7 @@ from django.db.models import Q from extras.filters import CustomFieldFilterSet from tenancy.models import Tenant +from utilities.filters import NullableModelMultipleChoiceFilter from .models import ( ConsolePort, ConsoleServerPort, Device, DeviceRole, DeviceType, Interface, InterfaceConnection, Manufacturer, Platform, PowerOutlet, PowerPort, Rack, RackGroup, RackRole, Site, @@ -15,12 +16,12 @@ class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet): action='search', label='Search', ) - tenant_id = django_filters.ModelMultipleChoiceFilter( + tenant_id = NullableModelMultipleChoiceFilter( name='tenant', queryset=Tenant.objects.all(), label='Tenant (ID)', ) - tenant = django_filters.ModelMultipleChoiceFilter( + tenant = NullableModelMultipleChoiceFilter( name='tenant', queryset=Tenant.objects.all(), to_field_name='slug', @@ -75,34 +76,34 @@ class RackFilter(CustomFieldFilterSet, django_filters.FilterSet): to_field_name='slug', label='Site (slug)', ) - group_id = django_filters.ModelMultipleChoiceFilter( + group_id = NullableModelMultipleChoiceFilter( name='group', queryset=RackGroup.objects.all(), label='Group (ID)', ) - group = django_filters.ModelMultipleChoiceFilter( + group = NullableModelMultipleChoiceFilter( name='group', queryset=RackGroup.objects.all(), to_field_name='slug', label='Group', ) - tenant_id = django_filters.ModelMultipleChoiceFilter( + tenant_id = NullableModelMultipleChoiceFilter( name='tenant', queryset=Tenant.objects.all(), label='Tenant (ID)', ) - tenant = django_filters.ModelMultipleChoiceFilter( + tenant = NullableModelMultipleChoiceFilter( name='tenant', queryset=Tenant.objects.all(), to_field_name='slug', label='Tenant (slug)', ) - role_id = django_filters.ModelMultipleChoiceFilter( + role_id = NullableModelMultipleChoiceFilter( name='role', queryset=RackRole.objects.all(), label='Role (ID)', ) - role = django_filters.ModelMultipleChoiceFilter( + role = NullableModelMultipleChoiceFilter( name='role', queryset=RackRole.objects.all(), to_field_name='slug', @@ -177,12 +178,12 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet): to_field_name='slug', label='Role (slug)', ) - tenant_id = django_filters.ModelMultipleChoiceFilter( + tenant_id = NullableModelMultipleChoiceFilter( name='tenant', queryset=Tenant.objects.all(), label='Tenant (ID)', ) - tenant = django_filters.ModelMultipleChoiceFilter( + tenant = NullableModelMultipleChoiceFilter( name='tenant', queryset=Tenant.objects.all(), to_field_name='slug', @@ -210,12 +211,12 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet): to_field_name='slug', label='Device model (slug)', ) - platform_id = django_filters.ModelMultipleChoiceFilter( + platform_id = NullableModelMultipleChoiceFilter( name='platform', queryset=Platform.objects.all(), label='Platform (ID)', ) - platform = django_filters.ModelMultipleChoiceFilter( + platform = NullableModelMultipleChoiceFilter( name='platform', queryset=Platform.objects.all(), to_field_name='slug', diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 7625955b4..e296a221b 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -120,7 +120,8 @@ class SiteBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): class SiteFilterForm(BootstrapMixin, CustomFieldFilterForm): model = Site - tenant = FilterChoiceField(choices=get_filter_choices(Tenant, id_field='slug', count_field='sites')) + tenant = FilterChoiceField(choices=get_filter_choices(Tenant, id_field='slug', count_field='sites', + null_option='None')) # @@ -246,10 +247,13 @@ class RackBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): class RackFilterForm(BootstrapMixin, CustomFieldFilterForm): model = Rack site = FilterChoiceField(choices=get_filter_choices(Site, id_field='slug', count_field='racks')) - group_id = FilterChoiceField(choices=get_filter_choices(RackGroup, select_related=['site'], count_field='racks'), + group_id = FilterChoiceField(choices=get_filter_choices(RackGroup, select_related=['site'], count_field='racks', + null_option='None'), label='Rack Group') - tenant = FilterChoiceField(choices=get_filter_choices(Tenant, id_field='slug', count_field='racks')) - role = FilterChoiceField(choices=get_filter_choices(RackRole, id_field='slug', count_field='racks')) + tenant = FilterChoiceField(choices=get_filter_choices(Tenant, id_field='slug', count_field='racks', + null_option='None')) + role = FilterChoiceField(choices=get_filter_choices(RackRole, id_field='slug', count_field='racks', + null_option='None')) # @@ -595,11 +599,13 @@ class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm): count_field='racks__devices'), label='Rack Group') role = FilterChoiceField(choices=get_filter_choices(DeviceRole, id_field='slug', count_field='devices')) - tenant = FilterChoiceField(choices=get_filter_choices(Tenant, id_field='slug', count_field='devices')) + tenant = FilterChoiceField(choices=get_filter_choices(Tenant, id_field='slug', count_field='devices', + null_option='None')) device_type_id = FilterChoiceField(choices=get_filter_choices(DeviceType, select_related=['manufacturer'], count_field='instances'), label='Type') - platform = FilterChoiceField(choices=get_filter_choices(Platform, id_field='slug', count_field='devices')) + platform = FilterChoiceField(choices=get_filter_choices(Platform, id_field='slug', count_field='devices', + null_option='None')) status = forms.NullBooleanField(required=False, widget=forms.Select(choices=FORM_STATUS_CHOICES)) diff --git a/netbox/ipam/filters.py b/netbox/ipam/filters.py index be2d127b5..faae390b3 100644 --- a/netbox/ipam/filters.py +++ b/netbox/ipam/filters.py @@ -7,6 +7,7 @@ from django.db.models import Q from dcim.models import Site, Device, Interface from extras.filters import CustomFieldFilterSet from tenancy.models import Tenant +from utilities.filters import NullableModelMultipleChoiceFilter from .models import RIR, Aggregate, VRF, Prefix, IPAddress, VLAN, VLANGroup, Role @@ -21,12 +22,12 @@ class VRFFilter(CustomFieldFilterSet, django_filters.FilterSet): lookup_type='icontains', label='Name', ) - tenant_id = django_filters.ModelMultipleChoiceFilter( + tenant_id = NullableModelMultipleChoiceFilter( name='tenant', queryset=Tenant.objects.all(), label='Tenant (ID)', ) - tenant = django_filters.ModelMultipleChoiceFilter( + tenant = NullableModelMultipleChoiceFilter( name='tenant', queryset=Tenant.objects.all(), to_field_name='slug', @@ -85,29 +86,34 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet): action='search_by_parent', label='Parent prefix', ) - vrf = django_filters.MethodFilter( - action='_vrf', + vrf = NullableModelMultipleChoiceFilter( + name='vrf', + queryset=VRF.objects.all(), label='VRF', ) # Duplicate of `vrf` for backward-compatibility - vrf_id = django_filters.MethodFilter( - action='_vrf', + vrf_id = NullableModelMultipleChoiceFilter( + name='vrf_id', + queryset=VRF.objects.all(), label='VRF', ) - tenant_id = django_filters.MethodFilter( - action='_tenant_id', + tenant_id = NullableModelMultipleChoiceFilter( + name='tenant', + queryset=Tenant.objects.all(), label='Tenant (ID)', ) - tenant = django_filters.MethodFilter( - action='_tenant', - label='Tenant', + tenant = NullableModelMultipleChoiceFilter( + name='tenant', + queryset=Tenant.objects.all(), + to_field_name='slug', + label='Tenant (slug)', ) - site_id = django_filters.ModelMultipleChoiceFilter( + site_id = NullableModelMultipleChoiceFilter( name='site', queryset=Site.objects.all(), label='Site (ID)', ) - site = django_filters.ModelMultipleChoiceFilter( + site = NullableModelMultipleChoiceFilter( name='site', queryset=Site.objects.all(), to_field_name='slug', @@ -122,12 +128,12 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet): name='vlan__vid', label='VLAN number (1-4095)', ) - role_id = django_filters.ModelMultipleChoiceFilter( + role_id = NullableModelMultipleChoiceFilter( name='role', queryset=Role.objects.all(), label='Role (ID)', ) - role = django_filters.ModelMultipleChoiceFilter( + role = NullableModelMultipleChoiceFilter( name='role', queryset=Role.objects.all(), to_field_name='slug', @@ -136,7 +142,7 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet): class Meta: model = Prefix - fields = ['family', 'site_id', 'site', 'vrf', 'vrf_id', 'vlan_id', 'vlan_vid', 'status', 'role_id', 'role'] + fields = ['family', 'site_id', 'site', 'vlan_id', 'vlan_vid', 'status', 'role_id', 'role'] def search(self, queryset, value): qs_filter = Q(description__icontains=value) @@ -157,17 +163,6 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet): except AddrFormatError: return queryset.none() - def _vrf(self, queryset, value): - if str(value) == '': - return queryset - try: - vrf_id = int(value) - except ValueError: - return queryset.none() - if vrf_id == 0: - return queryset.filter(vrf__isnull=True) - return queryset.filter(vrf__pk=value) - def _tenant(self, queryset, value): if str(value) == '': return queryset @@ -196,22 +191,27 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet): action='search_by_parent', label='Parent prefix', ) - vrf = django_filters.MethodFilter( - action='_vrf', + vrf = NullableModelMultipleChoiceFilter( + name='vrf', + queryset=VRF.objects.all(), label='VRF', ) # Duplicate of `vrf` for backward-compatibility - vrf_id = django_filters.MethodFilter( - action='_vrf', + vrf_id = NullableModelMultipleChoiceFilter( + name='vrf_id', + queryset=VRF.objects.all(), label='VRF', ) - tenant_id = django_filters.MethodFilter( - action='_tenant_id', + tenant_id = NullableModelMultipleChoiceFilter( + name='tenant', + queryset=Tenant.objects.all(), label='Tenant (ID)', ) - tenant = django_filters.MethodFilter( - action='_tenant', - label='Tenant', + tenant = NullableModelMultipleChoiceFilter( + name='tenant', + queryset=Tenant.objects.all(), + to_field_name='slug', + label='Tenant (slug)', ) device_id = django_filters.ModelMultipleChoiceFilter( name='interface__device', @@ -232,7 +232,7 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet): class Meta: model = IPAddress - fields = ['q', 'family', 'vrf_id', 'vrf', 'device_id', 'device', 'interface_id'] + fields = ['q', 'family', 'device_id', 'device', 'interface_id'] def search(self, queryset, value): qs_filter = Q(description__icontains=value) @@ -317,12 +317,12 @@ class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet): to_field_name='slug', label='Site (slug)', ) - group_id = django_filters.ModelMultipleChoiceFilter( + group_id = NullableModelMultipleChoiceFilter( name='group', queryset=VLANGroup.objects.all(), label='Group (ID)', ) - group = django_filters.ModelMultipleChoiceFilter( + group = NullableModelMultipleChoiceFilter( name='group', queryset=VLANGroup.objects.all(), to_field_name='slug', @@ -337,23 +337,23 @@ class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet): name='vid', label='VLAN number (1-4095)', ) - tenant_id = django_filters.ModelMultipleChoiceFilter( + tenant_id = NullableModelMultipleChoiceFilter( name='tenant', queryset=Tenant.objects.all(), label='Tenant (ID)', ) - tenant = django_filters.ModelMultipleChoiceFilter( + tenant = NullableModelMultipleChoiceFilter( name='tenant', queryset=Tenant.objects.all(), to_field_name='slug', label='Tenant (slug)', ) - role_id = django_filters.ModelMultipleChoiceFilter( + role_id = NullableModelMultipleChoiceFilter( name='role', queryset=Role.objects.all(), label='Role (ID)', ) - role = django_filters.ModelMultipleChoiceFilter( + role = NullableModelMultipleChoiceFilter( name='role', queryset=Role.objects.all(), to_field_name='slug', diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 45fed46f2..480777aa6 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -74,7 +74,8 @@ class VRFBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): class VRFFilterForm(BootstrapMixin, CustomFieldFilterForm): model = VRF - tenant = FilterChoiceField(choices=get_filter_choices(Tenant, id_field='slug', count_field='vrfs')) + tenant = FilterChoiceField(choices=get_filter_choices(Tenant, id_field='slug', count_field='vrfs', + null_option='None')) # @@ -272,13 +273,16 @@ class PrefixFilterForm(BootstrapMixin, CustomFieldFilterForm): 'placeholder': 'Network', })) family = forms.ChoiceField(required=False, choices=IP_FAMILY_CHOICES, label='Address Family') - vrf = FilterChoiceField(choices=get_filter_choices(VRF, count_field='prefixes', null_option=(0, 'Global')), + vrf = FilterChoiceField(choices=get_filter_choices(VRF, count_field='prefixes', null_option='Global'), label='VRF') - tenant = FilterChoiceField(choices=get_filter_choices(Tenant, id_field='slug', count_field='prefixes'), + tenant = FilterChoiceField(choices=get_filter_choices(Tenant, id_field='slug', count_field='prefixes', + null_option='None'), label='Tenant') status = FilterChoiceField(choices=prefix_status_choices) - site = FilterChoiceField(choices=get_filter_choices(Site, id_field='slug', count_field='prefixes')) - role = FilterChoiceField(choices=get_filter_choices(Role, id_field='slug', count_field='prefixes')) + site = FilterChoiceField(choices=get_filter_choices(Site, id_field='slug', count_field='prefixes', + null_option='None')) + role = FilterChoiceField(choices=get_filter_choices(Role, id_field='slug', count_field='prefixes', + null_option='None')) expand = forms.BooleanField(required=False, label='Expand prefix hierarchy') @@ -415,8 +419,10 @@ class IPAddressFilterForm(BootstrapMixin, CustomFieldFilterForm): 'placeholder': 'Prefix', })) family = forms.ChoiceField(required=False, choices=IP_FAMILY_CHOICES, label='Address Family') - vrf = FilterChoiceField(choices=get_filter_choices(VRF, count_field='ip_addresses'), label='VRF') - tenant = FilterChoiceField(choices=get_filter_choices(Tenant, id_field='slug', count_field='prefixes'), + vrf = FilterChoiceField(choices=get_filter_choices(VRF, count_field='ip_addresses', null_option='None'), + label='VRF') + tenant = FilterChoiceField(choices=get_filter_choices(Tenant, id_field='slug', count_field='ip_addresses', + null_option='None'), label='Tenant') @@ -521,8 +527,10 @@ def vlan_status_choices(): class VLANFilterForm(BootstrapMixin, CustomFieldFilterForm): model = VLAN site = FilterChoiceField(choices=get_filter_choices(Site, id_field='slug', count_field='vlans')) - group_id = FilterChoiceField(choices=get_filter_choices(VLANGroup, select_related=['site'], count_field='vlans'), + group_id = FilterChoiceField(choices=get_filter_choices(VLANGroup, select_related=['site'], count_field='vlans', + null_option='None'), label='VLAN Group') - tenant = FilterChoiceField(choices=get_filter_choices(Tenant, id_field='slug', count_field='vlans')) + tenant = FilterChoiceField(choices=get_filter_choices(Tenant, id_field='slug', count_field='vlans', + null_option='None')) status = FilterChoiceField(choices=vlan_status_choices) - role = FilterChoiceField(choices=get_filter_choices(Role, id_field='slug', count_field='vlans')) + role = FilterChoiceField(choices=get_filter_choices(Role, id_field='slug', count_field='vlans', null_option='None')) diff --git a/netbox/tenancy/filters.py b/netbox/tenancy/filters.py index 01d2d578d..6eeeb1e7b 100644 --- a/netbox/tenancy/filters.py +++ b/netbox/tenancy/filters.py @@ -3,6 +3,7 @@ import django_filters from django.db.models import Q from extras.filters import CustomFieldFilterSet +from utilities.filters import NullableModelMultipleChoiceFilter from .models import Tenant, TenantGroup @@ -11,12 +12,12 @@ class TenantFilter(CustomFieldFilterSet, django_filters.FilterSet): action='search', label='Search', ) - group_id = django_filters.ModelMultipleChoiceFilter( + group_id = NullableModelMultipleChoiceFilter( name='group', queryset=TenantGroup.objects.all(), label='Group (ID)', ) - group = django_filters.ModelMultipleChoiceFilter( + group = NullableModelMultipleChoiceFilter( name='group', queryset=TenantGroup.objects.all(), to_field_name='slug', diff --git a/netbox/tenancy/forms.py b/netbox/tenancy/forms.py index 8bbd05372..b011456df 100644 --- a/netbox/tenancy/forms.py +++ b/netbox/tenancy/forms.py @@ -77,4 +77,5 @@ class TenantBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): class TenantFilterForm(BootstrapMixin, CustomFieldFilterForm): model = Tenant - group = FilterChoiceField(choices=get_filter_choices(TenantGroup, id_field='slug', count_field='tenants')) + group = FilterChoiceField(choices=get_filter_choices(TenantGroup, id_field='slug', count_field='tenants', + null_option='None')) diff --git a/netbox/utilities/filters.py b/netbox/utilities/filters.py new file mode 100644 index 000000000..861bb11dd --- /dev/null +++ b/netbox/utilities/filters.py @@ -0,0 +1,43 @@ +import django_filters + +from django.db.models import Q + + +class NullableModelMultipleChoiceFilter(django_filters.MultipleChoiceFilter): + + def __init__(self, *args, **kwargs): + # Convert the queryset to a list of choices prefixed with a "None" option + queryset = kwargs.pop('queryset') + self.to_field_name = kwargs.pop('to_field_name', 'pk') + kwargs['choices'] = [(0, 'None')] + [(getattr(o, self.to_field_name), o) for o in queryset] + super(NullableModelMultipleChoiceFilter, self).__init__(*args, **kwargs) + + def filter(self, qs, value): + value = value or () # Make sure we have an iterable + + if self.is_noop(qs, value): + return qs + + # Even though not a noop, no point filtering if empty + if not value: + return qs + + q = Q() + for v in set(value): + # Filtering on NULL + if v == str(0): + arg = {'{}__isnull'.format(self.name): True} + # Filtering on a related field (e.g. slug) + elif self.to_field_name != 'pk': + arg = {'{}__{}'.format(self.name, self.to_field_name): v} + # Filtering on primary key + else: + arg = {self.name: v} + if self.conjoined: + qs = self.get_method(qs)(**arg) + else: + q |= Q(**arg) + if self.distinct: + return self.get_method(qs)(q).distinct() + + return self.get_method(qs)(q) diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index ed1ce1533..dbbee9f28 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -43,7 +43,7 @@ def get_filter_choices(model, id_field='pk', select_related=[], count_field=None :param id_field: Field to use as the object identifier :param select_related: Any related tables to include :param count_field: The field to use for a child COUNT() (optional) - :param null_option: A (value, label) tuple to include at the beginning of the list serving as "null" + :param null_option: A choice to include at the beginning of the list serving as "null" """ queryset = model.objects.all() if select_related: @@ -54,7 +54,7 @@ def get_filter_choices(model, id_field='pk', select_related=[], count_field=None else: choices = [(getattr(obj, id_field), u'{}'.format(obj)) for obj in queryset] if null_option: - choices = [null_option] + choices + choices = [(0, null_option)] + choices return choices From 814a0e7344dcc87ce654f01025ddbdddd5109541 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 16 Sep 2016 10:31:42 -0400 Subject: [PATCH 11/23] Tweak to #493 --- netbox/utilities/forms.py | 1 - 1 file changed, 1 deletion(-) diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index 28e4cdbb2..ce05c8ee3 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -168,7 +168,6 @@ class CSVDataField(forms.CharField): self.help_text = 'Enter one line per record in CSV format.' def utf_8_encoder(self, unicode_csv_data): - # convert csv,reader to utf-8e for line in unicode_csv_data: yield line.encode('utf-8') From ce9d853883820d66aafdcf204840022c42596c66 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 16 Sep 2016 11:50:02 -0400 Subject: [PATCH 12/23] Closes #415: Added an expand/collapse toggle button to the prefix list --- netbox/templates/ipam/prefix_list.html | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/netbox/templates/ipam/prefix_list.html b/netbox/templates/ipam/prefix_list.html index 14ac861a2..df790f9c6 100644 --- a/netbox/templates/ipam/prefix_list.html +++ b/netbox/templates/ipam/prefix_list.html @@ -6,6 +6,15 @@ {% block content %}
+ + {% if 'expand' in request.GET %} + + Collapse all + {% else %} + + Expand all + {% endif %} + {% if perms.ipam.add_prefix %} From 64326e7c9da59e316b4d88ad64b16e00e55eb985 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 16 Sep 2016 13:41:53 -0400 Subject: [PATCH 13/23] Closes #552: Added a None filter option for custom select fields --- netbox/extras/filters.py | 17 +++++++++++++++-- netbox/extras/forms.py | 12 +++++------- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/netbox/extras/filters.py b/netbox/extras/filters.py index d8ccbf986..bcd9f175f 100644 --- a/netbox/extras/filters.py +++ b/netbox/extras/filters.py @@ -2,7 +2,7 @@ import django_filters from django.contrib.contenttypes.models import ContentType -from .models import CustomField +from .models import CF_TYPE_SELECT, CustomField class CustomFieldFilter(django_filters.Filter): @@ -10,9 +10,22 @@ class CustomFieldFilter(django_filters.Filter): Filter objects by the presence of a CustomFieldValue. The filter's name is used as the CustomField name. """ + def __init__(self, cf_type, *args, **kwargs): + self.cf_type = cf_type + super(CustomFieldFilter, self).__init__(*args, **kwargs) + def filter(self, queryset, value): + # Skip filter on empty value if not value.strip(): return queryset + # Treat 0 as None for Select fields + try: + if self.cf_type == CF_TYPE_SELECT and int(value) == 0: + return queryset.exclude( + custom_field_values__field__name=self.name, + ) + except ValueError: + pass return queryset.filter( custom_field_values__field__name=self.name, custom_field_values__serialized_value=value, @@ -30,4 +43,4 @@ class CustomFieldFilterSet(django_filters.FilterSet): obj_type = ContentType.objects.get_for_model(self._meta.model) custom_fields = CustomField.objects.filter(obj_type=obj_type, is_filterable=True) for cf in custom_fields: - self.filters['cf_{}'.format(cf.name)] = CustomFieldFilter(name=cf.name) + self.filters['cf_{}'.format(cf.name)] = CustomFieldFilter(name=cf.name, cf_type=cf.type) diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index 8ec70d2e8..06c5403c6 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -47,14 +47,12 @@ def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=F # Select elif cf.type == CF_TYPE_SELECT: - if bulk_edit: - choices = [(cfc.pk, cfc) for cfc in cf.choices.all()] - if not cf.required: - choices = [(0, 'None')] + choices + choices = [(cfc.pk, cfc) for cfc in cf.choices.all()] + if not cf.required: + choices = [(0, 'None')] + choices + if bulk_edit or filterable_only: choices = [(None, '---------')] + choices - field = forms.TypedChoiceField(choices=choices, coerce=int, required=cf.required) - else: - field = forms.ModelChoiceField(queryset=cf.choices.all(), required=cf.required) + field = forms.TypedChoiceField(choices=choices, coerce=int, required=cf.required) # URL elif cf.type == CF_TYPE_URL: From 513408f16af72eac0930c993fc4d7d8fda3fe9ed Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 19 Sep 2016 10:31:40 -0400 Subject: [PATCH 14/23] Fixes #562: Fixed bulk interface creation --- netbox/dcim/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index f3b2f4bf1..44b39573c 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1399,7 +1399,7 @@ class InterfaceBulkAddView(PermissionRequiredMixin, BulkEditView): template_name = 'dcim/interface_add_multi.html' default_redirect_url = 'dcim:device_list' - def update_objects(self, pk_list, form): + def update_objects(self, pk_list, form, fields): selected_devices = Device.objects.filter(pk__in=pk_list) interfaces = [] From d0c92b4f8a0fbe1393ca12d0bfe7835f03cbad9e Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 19 Sep 2016 10:32:38 -0400 Subject: [PATCH 15/23] Removed obsolete dependency --- netbox/utilities/views.py | 1 - 1 file changed, 1 deletion(-) diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index a719d3fbf..cc888ad10 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -10,7 +10,6 @@ from django.forms import ModelMultipleChoiceField, MultipleHiddenInput, TypedCho from django.http import HttpResponse, HttpResponseRedirect from django.shortcuts import get_object_or_404, redirect, render from django.template import TemplateSyntaxError -from django.utils.decorators import method_decorator from django.utils.http import is_safe_url from django.views.generic import View From b10e29aaac61b3109181fcd6ed347cbfc77dfa97 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 19 Sep 2016 16:11:37 -0400 Subject: [PATCH 16/23] Closes #561: Make custom fields accessible from within export templates --- docs/data-model/extras.md | 2 ++ netbox/extras/models.py | 13 ++++++++++- netbox/templates/circuits/provider.html | 2 +- netbox/templates/dcim/device.html | 2 +- netbox/templates/dcim/rack.html | 2 +- netbox/templates/dcim/site.html | 2 +- netbox/templates/ipam/aggregate.html | 2 +- netbox/templates/tenancy/tenant.html | 2 +- netbox/utilities/views.py | 29 +++++++++++++++++++++---- 9 files changed, 45 insertions(+), 11 deletions(-) diff --git a/docs/data-model/extras.md b/docs/data-model/extras.md index d6c863983..9d69af40a 100644 --- a/docs/data-model/extras.md +++ b/docs/data-model/extras.md @@ -35,6 +35,8 @@ Each export template is associated with a certain type of object. For instance, Export templates are written in [Django's template language](https://docs.djangoproject.com/en/1.9/ref/templates/language/), which is very similar to Jinja2. The list of objects returned from the database is stored in the `queryset` variable. Typically, you'll want to iterate through this list using a for loop. +To access custom fields of an object within a template, use the `cf` attribute. For example, `{{ obj.cf.color }}` will return the value (if any) for a custom field named `color` on `obj`. + A MIME type and file extension can optionally be defined for each export template. The default MIME type is `text/plain`. ## Example diff --git a/netbox/extras/models.py b/netbox/extras/models.py index ce5b1d43f..4bc9f3550 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -67,7 +67,18 @@ ACTION_CHOICES = ( class CustomFieldModel(object): - def custom_fields(self): + def cf(self): + """ + Name-based CustomFieldValue accessor for use in templates + """ + if not hasattr(self, 'custom_fields'): + return dict() + return {field.name: value for field, value in self.custom_fields.items()} + + def get_custom_fields(self): + """ + Return a dictionary of custom fields for a single object in the form {: value}. + """ # Find all custom fields applicable to this type of object content_type = ContentType.objects.get_for_model(self) diff --git a/netbox/templates/circuits/provider.html b/netbox/templates/circuits/provider.html index 742dbcc3e..c59858626 100644 --- a/netbox/templates/circuits/provider.html +++ b/netbox/templates/circuits/provider.html @@ -105,7 +105,7 @@
- {% with provider.custom_fields as custom_fields %} + {% with provider.get_custom_fields as custom_fields %} {% include 'inc/custom_fields_panel.html' %} {% endwith %}
diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index 754c9c2a8..551241715 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -144,7 +144,7 @@
- {% with device.custom_fields as custom_fields %} + {% with device.get_custom_fields as custom_fields %} {% include 'inc/custom_fields_panel.html' %} {% endwith %} {% if request.user.is_authenticated %} diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index 11d7fb411..4c2aef15d 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -132,7 +132,7 @@ - {% with rack.custom_fields as custom_fields %} + {% with rack.get_custom_fields as custom_fields %} {% include 'inc/custom_fields_panel.html' %} {% endwith %}
diff --git a/netbox/templates/dcim/site.html b/netbox/templates/dcim/site.html index ffcf382ab..ccf2c9673 100644 --- a/netbox/templates/dcim/site.html +++ b/netbox/templates/dcim/site.html @@ -111,7 +111,7 @@
- {% with site.custom_fields as custom_fields %} + {% with site.get_custom_fields as custom_fields %} {% include 'inc/custom_fields_panel.html' %} {% endwith %}
diff --git a/netbox/templates/ipam/aggregate.html b/netbox/templates/ipam/aggregate.html index e2b4086b1..d95494c67 100644 --- a/netbox/templates/ipam/aggregate.html +++ b/netbox/templates/ipam/aggregate.html @@ -82,7 +82,7 @@ {% include 'inc/created_updated.html' with obj=aggregate %}
- {% with aggregate.custom_fields as custom_fields %} + {% with aggregate.get_custom_fields as custom_fields %} {% include 'inc/custom_fields_panel.html' %} {% endwith %}
diff --git a/netbox/templates/tenancy/tenant.html b/netbox/templates/tenancy/tenant.html index 6fe140e53..c3e640de1 100644 --- a/netbox/templates/tenancy/tenant.html +++ b/netbox/templates/tenancy/tenant.html @@ -65,7 +65,7 @@ - {% with tenant.custom_fields as custom_fields %} + {% with tenant.get_custom_fields as custom_fields %} {% include 'inc/custom_fields_panel.html' %} {% endwith %}
diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index cc888ad10..5e8b01d6f 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -1,3 +1,4 @@ +from collections import OrderedDict from django_tables2 import RequestConfig from django.contrib import messages @@ -14,13 +15,26 @@ from django.utils.http import is_safe_url from django.views.generic import View from extras.forms import CustomFieldForm -from extras.models import CustomFieldValue, ExportTemplate, UserAction +from extras.models import CustomField, CustomFieldValue, ExportTemplate, UserAction from .error_handlers import handle_protectederror from .forms import ConfirmationForm from .paginator import EnhancedPaginator +class annotate_custom_fields: + + def __init__(self, queryset, custom_fields): + self.queryset = queryset + self.custom_fields = custom_fields + + def __iter__(self): + for obj in self.queryset: + values_dict = {cfv.field_id: cfv.value for cfv in obj.custom_field_values.all()} + obj.custom_fields = OrderedDict([(field, values_dict.get(field.pk)) for field in self.custom_fields]) + yield obj + + class ObjectListView(View): queryset = None filter = None @@ -38,19 +52,26 @@ class ObjectListView(View): if self.filter: self.queryset = self.filter(request.GET, self.queryset).qs + # If this type of object has one or more custom fields, prefetch any relevant custom field values + custom_fields = CustomField.objects.filter(obj_type=ContentType.objects.get_for_model(model))\ + .prefetch_related('choices') + if custom_fields: + self.queryset = self.queryset.prefetch_related('custom_field_values') + # Check for export template rendering if request.GET.get('export'): et = get_object_or_404(ExportTemplate, content_type=object_ct, name=request.GET.get('export')) + queryset = annotate_custom_fields(self.queryset, custom_fields) if custom_fields else self.queryset try: - response = et.to_response(context_dict={'queryset': self.queryset.all()}, - filename='netbox_{}'.format(self.queryset.model._meta.verbose_name_plural)) + response = et.to_response(context_dict={'queryset': queryset}, + filename='netbox_{}'.format(model._meta.verbose_name_plural)) return response except TemplateSyntaxError: messages.error(request, "There was an error rendering the selected export template ({})." .format(et.name)) # Fall back to built-in CSV export elif 'export' in request.GET and hasattr(model, 'to_csv'): - output = '\n'.join([obj.to_csv() for obj in self.queryset.all()]) + output = '\n'.join([obj.to_csv() for obj in self.queryset]) response = HttpResponse( output, content_type='text/csv' From 687e68db695a7b2bf4c94770c32ac720f24f6ae5 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 19 Sep 2016 16:13:02 -0400 Subject: [PATCH 17/23] Fixes #564: Display custom fields for all applicable objects --- netbox/templates/circuits/circuit.html | 3 +++ netbox/templates/ipam/ipaddress.html | 3 +++ netbox/templates/ipam/prefix.html | 3 +++ netbox/templates/ipam/vlan.html | 3 +++ netbox/templates/ipam/vrf.html | 3 +++ 5 files changed, 15 insertions(+) diff --git a/netbox/templates/circuits/circuit.html b/netbox/templates/circuits/circuit.html index 8844dc43a..46f37c44f 100644 --- a/netbox/templates/circuits/circuit.html +++ b/netbox/templates/circuits/circuit.html @@ -104,6 +104,9 @@
+ {% with circuit.get_custom_fields as custom_fields %} + {% include 'inc/custom_fields_panel.html' %} + {% endwith %} {% include 'inc/created_updated.html' with obj=circuit %}
diff --git a/netbox/templates/ipam/ipaddress.html b/netbox/templates/ipam/ipaddress.html index 56f95479b..7524b00fe 100644 --- a/netbox/templates/ipam/ipaddress.html +++ b/netbox/templates/ipam/ipaddress.html @@ -121,6 +121,9 @@
+ {% with ipaddress.get_custom_fields as custom_fields %} + {% include 'inc/custom_fields_panel.html' %} + {% endwith %} {% include 'inc/created_updated.html' with obj=ipaddress %}
diff --git a/netbox/templates/ipam/prefix.html b/netbox/templates/ipam/prefix.html index 24a40ed66..89f06bb47 100644 --- a/netbox/templates/ipam/prefix.html +++ b/netbox/templates/ipam/prefix.html @@ -101,6 +101,9 @@
+ {% with prefix.get_custom_fields as custom_fields %} + {% include 'inc/custom_fields_panel.html' %} + {% endwith %} {% include 'inc/created_updated.html' with obj=prefix %}
diff --git a/netbox/templates/ipam/vlan.html b/netbox/templates/ipam/vlan.html index 5b9d4cd3c..f25de0295 100644 --- a/netbox/templates/ipam/vlan.html +++ b/netbox/templates/ipam/vlan.html @@ -110,6 +110,9 @@ + {% with vlan.get_custom_fields as custom_fields %} + {% include 'inc/custom_fields_panel.html' %} + {% endwith %} {% include 'inc/created_updated.html' with obj=vlan %}
diff --git a/netbox/templates/ipam/vrf.html b/netbox/templates/ipam/vrf.html index 899b14f54..dc8903418 100644 --- a/netbox/templates/ipam/vrf.html +++ b/netbox/templates/ipam/vrf.html @@ -82,6 +82,9 @@
+ {% with vrf.get_custom_fields as custom_fields %} + {% include 'inc/custom_fields_panel.html' %} + {% endwith %} {% include 'inc/created_updated.html' with obj=vrf %}
From e3f0a123139aa730db240ec8d1af541b74a923d8 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 19 Sep 2016 16:21:42 -0400 Subject: [PATCH 18/23] PEP8 fix --- netbox/utilities/forms.py | 1 - 1 file changed, 1 deletion(-) diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index ce05c8ee3..9d5d8496c 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -58,7 +58,6 @@ def get_filter_choices(model, id_field='pk', select_related=[], count_field=None return choices - # # Widgets # From e618bf40ec98c2f0010749bbf68723b0f8df0aeb Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 20 Sep 2016 11:08:25 -0400 Subject: [PATCH 19/23] Reimplemented FilterChoiceField --- netbox/circuits/forms.py | 17 +++++++----- netbox/dcim/forms.py | 48 ++++++++++++++++------------------ netbox/ipam/forms.py | 54 +++++++++++++++++++-------------------- netbox/secrets/forms.py | 7 +++-- netbox/tenancy/forms.py | 9 +++---- netbox/utilities/forms.py | 44 ++++++++++++++----------------- 6 files changed, 84 insertions(+), 95 deletions(-) diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py index 233741be8..f3c210dc2 100644 --- a/netbox/circuits/forms.py +++ b/netbox/circuits/forms.py @@ -1,4 +1,5 @@ from django import forms +from django.db.models import Count from dcim.models import Site, Device, Interface, Rack, IFACE_FF_VIRTUAL from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm @@ -6,7 +7,7 @@ from tenancy.forms import bulkedit_tenant_choices from tenancy.models import Tenant from utilities.forms import ( APISelect, BootstrapMixin, BulkImportForm, CommentField, CSVDataField, FilterChoiceField, Livesearch, SmallTextarea, - SlugField, get_filter_choices, + SlugField, ) from .models import Circuit, CircuitType, Provider @@ -59,7 +60,7 @@ class ProviderBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): class ProviderFilterForm(BootstrapMixin, CustomFieldFilterForm): model = Provider - site = FilterChoiceField(choices=get_filter_choices(Site, id_field='slug')) + site = FilterChoiceField(queryset=Site.objects.all(), to_field_name='slug') # @@ -185,8 +186,10 @@ class CircuitBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): class CircuitFilterForm(BootstrapMixin, CustomFieldFilterForm): model = Circuit - type = FilterChoiceField(choices=get_filter_choices(CircuitType, id_field='slug', count_field='circuits')) - provider = FilterChoiceField(choices=get_filter_choices(Provider, id_field='slug', count_field='circuits')) - tenant = FilterChoiceField(choices=get_filter_choices(Tenant, id_field='slug', count_field='circuits', - null_option='None')) - site = FilterChoiceField(choices=get_filter_choices(Site, id_field='slug', count_field='circuits')) + type = FilterChoiceField(queryset=CircuitType.objects.annotate(filter_count=Count('circuits')), + to_field_name='slug') + provider = FilterChoiceField(queryset=Provider.objects.annotate(filter_count=Count('circuits')), + to_field_name='slug') + tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('circuits')), to_field_name='slug', + null_option=(0, 'None')) + site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('circuits')), to_field_name='slug') diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index e296a221b..01fd8abb4 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -1,7 +1,7 @@ import re from django import forms -from django.db.models import Q +from django.db.models import Count, Q from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm from ipam.models import IPAddress @@ -10,7 +10,6 @@ from tenancy.models import Tenant from utilities.forms import ( APISelect, add_blank_choice, BootstrapMixin, BulkImportForm, CommentField, CSVDataField, ExpandableNameField, FilterChoiceField, FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SmallTextarea, SlugField, - get_filter_choices ) from .models import ( @@ -120,8 +119,8 @@ class SiteBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): class SiteFilterForm(BootstrapMixin, CustomFieldFilterForm): model = Site - tenant = FilterChoiceField(choices=get_filter_choices(Tenant, id_field='slug', count_field='sites', - null_option='None')) + tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('sites')), to_field_name='slug', + null_option=(0, 'None')) # @@ -137,7 +136,7 @@ class RackGroupForm(forms.ModelForm, BootstrapMixin): class RackGroupFilterForm(forms.Form, BootstrapMixin): - site = FilterChoiceField(choices=get_filter_choices(Site, id_field='slug', count_field='rack_groups')) + site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('rack_groups')), to_field_name='slug') # @@ -246,14 +245,13 @@ class RackBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): class RackFilterForm(BootstrapMixin, CustomFieldFilterForm): model = Rack - site = FilterChoiceField(choices=get_filter_choices(Site, id_field='slug', count_field='racks')) - group_id = FilterChoiceField(choices=get_filter_choices(RackGroup, select_related=['site'], count_field='racks', - null_option='None'), - label='Rack Group') - tenant = FilterChoiceField(choices=get_filter_choices(Tenant, id_field='slug', count_field='racks', - null_option='None')) - role = FilterChoiceField(choices=get_filter_choices(RackRole, id_field='slug', count_field='racks', - null_option='None')) + site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('racks')), to_field_name='slug') + group_id = FilterChoiceField(queryset=RackGroup.objects.select_related('site') + .annotate(filter_count=Count('racks')), label='Rack group', null_option=(0, 'None')) + tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('racks')), to_field_name='slug', + null_option=(0, 'None')) + role = FilterChoiceField(queryset=RackRole.objects.annotate(filter_count=Count('racks')), to_field_name='slug', + null_option=(0, 'None')) # @@ -288,8 +286,8 @@ class DeviceTypeBulkEditForm(forms.Form, BootstrapMixin): class DeviceTypeFilterForm(forms.Form, BootstrapMixin): - manufacturer = FilterChoiceField(choices=get_filter_choices(Manufacturer, id_field='slug', - count_field='device_types')) + manufacturer = FilterChoiceField(queryset=Manufacturer.objects.annotate(filter_count=Count('device_types')), + to_field_name='slug') # @@ -594,18 +592,16 @@ class DeviceBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm): model = Device - site = FilterChoiceField(choices=get_filter_choices(Site, id_field='slug', count_field='racks__devices')) - rack_group_id = FilterChoiceField(choices=get_filter_choices(RackGroup, select_related=['site'], - count_field='racks__devices'), + site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('racks__devices')), to_field_name='slug') + rack_group_id = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('racks__devices')), label='Rack Group') - role = FilterChoiceField(choices=get_filter_choices(DeviceRole, id_field='slug', count_field='devices')) - tenant = FilterChoiceField(choices=get_filter_choices(Tenant, id_field='slug', count_field='devices', - null_option='None')) - device_type_id = FilterChoiceField(choices=get_filter_choices(DeviceType, select_related=['manufacturer'], - count_field='instances'), - label='Type') - platform = FilterChoiceField(choices=get_filter_choices(Platform, id_field='slug', count_field='devices', - null_option='None')) + role = FilterChoiceField(queryset=DeviceRole.objects.annotate(filter_count=Count('devices')), to_field_name='slug') + tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('devices')), to_field_name='slug', + null_option=(0, 'None')) + device_type_id = FilterChoiceField(queryset=DeviceType.objects.select_related('manufacturer') + .annotate(filter_count=Count('instances')), label='Type') + platform = FilterChoiceField(queryset=Platform.objects.annotate(filter_count=Count('devices')), + to_field_name='slug', null_option=(0, 'None')) status = forms.NullBooleanField(required=False, widget=forms.Select(choices=FORM_STATUS_CHOICES)) diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 480777aa6..b2a83d4c4 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -7,7 +7,6 @@ from tenancy.forms import bulkedit_tenant_choices from tenancy.models import Tenant from utilities.forms import ( APISelect, BootstrapMixin, CSVDataField, BulkImportForm, FilterChoiceField, Livesearch, SlugField, - get_filter_choices, ) from .models import ( @@ -74,8 +73,8 @@ class VRFBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): class VRFFilterForm(BootstrapMixin, CustomFieldFilterForm): model = VRF - tenant = FilterChoiceField(choices=get_filter_choices(Tenant, id_field='slug', count_field='vrfs', - null_option='None')) + tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('vrfs')), to_field_name='slug', + null_option=(0, None)) # @@ -129,7 +128,8 @@ class AggregateBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): class AggregateFilterForm(BootstrapMixin, CustomFieldFilterForm): model = Aggregate family = forms.ChoiceField(required=False, choices=IP_FAMILY_CHOICES, label='Address Family') - rir = FilterChoiceField(choices=get_filter_choices(RIR, id_field='slug', count_field='aggregates'), label='RIR') + rir = FilterChoiceField(queryset=RIR.objects.annotate(filter_count=Count('aggregates')), to_field_name='slug', + label='RIR') # @@ -273,16 +273,15 @@ class PrefixFilterForm(BootstrapMixin, CustomFieldFilterForm): 'placeholder': 'Network', })) family = forms.ChoiceField(required=False, choices=IP_FAMILY_CHOICES, label='Address Family') - vrf = FilterChoiceField(choices=get_filter_choices(VRF, count_field='prefixes', null_option='Global'), - label='VRF') - tenant = FilterChoiceField(choices=get_filter_choices(Tenant, id_field='slug', count_field='prefixes', - null_option='None'), - label='Tenant') - status = FilterChoiceField(choices=prefix_status_choices) - site = FilterChoiceField(choices=get_filter_choices(Site, id_field='slug', count_field='prefixes', - null_option='None')) - role = FilterChoiceField(choices=get_filter_choices(Role, id_field='slug', count_field='prefixes', - null_option='None')) + vrf = FilterChoiceField(queryset=VRF.objects.annotate(filter_count=Count('prefixes')), to_field_name='slug', + label='VRF', null_option=(0, 'Global')) + tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('prefixes')), to_field_name='slug', + null_option=(0, 'None')) + status = forms.MultipleChoiceField(choices=prefix_status_choices) + site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('prefixes')), to_field_name='slug', + null_option=(0, 'None')) + role = FilterChoiceField(queryset=Role.objects.annotate(filter_count=Count('prefixes')), to_field_name='slug', + null_option=(0, 'None')) expand = forms.BooleanField(required=False, label='Expand prefix hierarchy') @@ -419,11 +418,10 @@ class IPAddressFilterForm(BootstrapMixin, CustomFieldFilterForm): 'placeholder': 'Prefix', })) family = forms.ChoiceField(required=False, choices=IP_FAMILY_CHOICES, label='Address Family') - vrf = FilterChoiceField(choices=get_filter_choices(VRF, count_field='ip_addresses', null_option='None'), - label='VRF') - tenant = FilterChoiceField(choices=get_filter_choices(Tenant, id_field='slug', count_field='ip_addresses', - null_option='None'), - label='Tenant') + vrf = FilterChoiceField(queryset=VRF.objects.annotate(filter_count=Count('ip_addresses')), to_field_name='slug', + label='VRF', null_option=(0, 'Global')) + tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('ip_addresses')), + to_field_name='slug', null_option=(0, 'None')) # @@ -439,7 +437,7 @@ class VLANGroupForm(forms.ModelForm, BootstrapMixin): class VLANGroupFilterForm(forms.Form, BootstrapMixin): - site = FilterChoiceField(choices=get_filter_choices(Site, id_field='slug', count_field='vlan_groups')) + site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('vlan_groups')), to_field_name='slug') # @@ -526,11 +524,11 @@ def vlan_status_choices(): class VLANFilterForm(BootstrapMixin, CustomFieldFilterForm): model = VLAN - site = FilterChoiceField(choices=get_filter_choices(Site, id_field='slug', count_field='vlans')) - group_id = FilterChoiceField(choices=get_filter_choices(VLANGroup, select_related=['site'], count_field='vlans', - null_option='None'), - label='VLAN Group') - tenant = FilterChoiceField(choices=get_filter_choices(Tenant, id_field='slug', count_field='vlans', - null_option='None')) - status = FilterChoiceField(choices=vlan_status_choices) - role = FilterChoiceField(choices=get_filter_choices(Role, id_field='slug', count_field='vlans', null_option='None')) + site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('vlans')), to_field_name='slug') + group_id = FilterChoiceField(queryset=VLANGroup.objects.annotate(filter_count=Count('vlans')), label='VLAN group', + null_option=(0, 'None')) + tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('vlans')), to_field_name='slug', + null_option=(0, 'None')) + status = forms.MultipleChoiceField(choices=vlan_status_choices) + role = FilterChoiceField(queryset=Role.objects.annotate(filter_count=Count('vlans')), to_field_name='slug', + null_option=(0, 'None')) diff --git a/netbox/secrets/forms.py b/netbox/secrets/forms.py index e3ebb7500..d8368cc32 100644 --- a/netbox/secrets/forms.py +++ b/netbox/secrets/forms.py @@ -2,11 +2,10 @@ from Crypto.Cipher import PKCS1_OAEP from Crypto.PublicKey import RSA from django import forms +from django.db.models import Count from dcim.models import Device -from utilities.forms import ( - BootstrapMixin, BulkImportForm, CSVDataField, FilterChoiceField, SlugField, get_filter_choices, -) +from utilities.forms import BootstrapMixin, BulkImportForm, CSVDataField, FilterChoiceField, SlugField from .models import Secret, SecretRole, UserKey @@ -97,7 +96,7 @@ class SecretBulkEditForm(forms.Form, BootstrapMixin): class SecretFilterForm(forms.Form, BootstrapMixin): - role = FilterChoiceField(choices=get_filter_choices(SecretRole, id_field='slug', count_field='secrets')) + role = FilterChoiceField(queryset=SecretRole.objects.annotate(filter_count=Count('secrets')), to_field_name='slug') # diff --git a/netbox/tenancy/forms.py b/netbox/tenancy/forms.py index b011456df..b49e972a7 100644 --- a/netbox/tenancy/forms.py +++ b/netbox/tenancy/forms.py @@ -1,9 +1,8 @@ from django import forms +from django.db.models import Count from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm -from utilities.forms import ( - BootstrapMixin, BulkImportForm, CommentField, CSVDataField, FilterChoiceField, SlugField, get_filter_choices, -) +from utilities.forms import BootstrapMixin, BulkImportForm, CommentField, CSVDataField, FilterChoiceField, SlugField from .models import Tenant, TenantGroup @@ -77,5 +76,5 @@ class TenantBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): class TenantFilterForm(BootstrapMixin, CustomFieldFilterForm): model = Tenant - group = FilterChoiceField(choices=get_filter_choices(TenantGroup, id_field='slug', count_field='tenants', - null_option='None')) + group = FilterChoiceField(queryset=TenantGroup.objects.annotate(filter_count=Count('tenants')), + to_field_name='slug', null_option=(0, 'None')) diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index 9d5d8496c..247ea9aed 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -1,4 +1,5 @@ import csv +import itertools import re from django import forms @@ -35,29 +36,6 @@ def add_blank_choice(choices): return ((None, '---------'),) + choices -def get_filter_choices(model, id_field='pk', select_related=[], count_field=None, null_option=None): - """ - Return a list of choices suitable for a ChoiceField. - - :param model: The base model to use for the queryset - :param id_field: Field to use as the object identifier - :param select_related: Any related tables to include - :param count_field: The field to use for a child COUNT() (optional) - :param null_option: A choice to include at the beginning of the list serving as "null" - """ - queryset = model.objects.all() - if select_related: - queryset = queryset.select_related(*select_related) - if count_field: - queryset = queryset.annotate(child_count=Count(count_field)) - choices = [(getattr(obj, id_field), u'{} ({})'.format(obj, obj.child_count)) for obj in queryset] - else: - choices = [(getattr(obj, id_field), u'{}'.format(obj)) for obj in queryset] - if null_option: - choices = [(0, null_option)] + choices - return choices - - # # Widgets # @@ -250,15 +228,31 @@ class SlugField(forms.SlugField): self.widget.attrs['slug-source'] = slug_source -class FilterChoiceField(forms.MultipleChoiceField): +class FilterChoiceField(forms.ModelMultipleChoiceField): + iterator = forms.models.ModelChoiceIterator - def __init__(self, *args, **kwargs): + def __init__(self, null_option=None, *args, **kwargs): + self.null_option = null_option if 'required' not in kwargs: kwargs['required'] = False if 'widget' not in kwargs: kwargs['widget'] = forms.SelectMultiple(attrs={'size': 6}) super(FilterChoiceField, self).__init__(*args, **kwargs) + def label_from_instance(self, obj): + if hasattr(obj, 'filter_count'): + return u'{} ({})'.format(obj, obj.filter_count) + return force_text(obj) + + def _get_choices(self): + if hasattr(self, '_choices'): + return self._choices + if self.null_option is not None: + return itertools.chain([self.null_option], self.iterator(self)) + return self.iterator(self) + + choices = property(_get_choices, forms.ChoiceField._set_choices) + # # Forms From 6ccc6244dd8874237ce14cbfa3e12a13b9844bf7 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 20 Sep 2016 11:25:16 -0400 Subject: [PATCH 20/23] Corrected PrefixFilterForm --- netbox/ipam/forms.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index b2a83d4c4..e352bfb0e 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -273,7 +273,7 @@ class PrefixFilterForm(BootstrapMixin, CustomFieldFilterForm): 'placeholder': 'Network', })) family = forms.ChoiceField(required=False, choices=IP_FAMILY_CHOICES, label='Address Family') - vrf = FilterChoiceField(queryset=VRF.objects.annotate(filter_count=Count('prefixes')), to_field_name='slug', + vrf = FilterChoiceField(queryset=VRF.objects.annotate(filter_count=Count('prefixes')), to_field_name='rd', label='VRF', null_option=(0, 'Global')) tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('prefixes')), to_field_name='slug', null_option=(0, 'None')) From b2684aeefc387777727c894ad9bc9ca290c12295 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 20 Sep 2016 11:29:30 -0400 Subject: [PATCH 21/23] status filter fields should not be required --- netbox/ipam/forms.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index e352bfb0e..b2b39177e 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -277,7 +277,7 @@ class PrefixFilterForm(BootstrapMixin, CustomFieldFilterForm): label='VRF', null_option=(0, 'Global')) tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('prefixes')), to_field_name='slug', null_option=(0, 'None')) - status = forms.MultipleChoiceField(choices=prefix_status_choices) + status = forms.MultipleChoiceField(choices=prefix_status_choices, required=False) site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('prefixes')), to_field_name='slug', null_option=(0, 'None')) role = FilterChoiceField(queryset=Role.objects.annotate(filter_count=Count('prefixes')), to_field_name='slug', @@ -529,6 +529,6 @@ class VLANFilterForm(BootstrapMixin, CustomFieldFilterForm): null_option=(0, 'None')) tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('vlans')), to_field_name='slug', null_option=(0, 'None')) - status = forms.MultipleChoiceField(choices=vlan_status_choices) + status = forms.MultipleChoiceField(choices=vlan_status_choices, required=False) role = FilterChoiceField(queryset=Role.objects.annotate(filter_count=Count('vlans')), to_field_name='slug', null_option=(0, 'None')) From 0444ac7db9faa06bbe4125eb9a17a90dc8763a5f Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 20 Sep 2016 15:48:58 -0400 Subject: [PATCH 22/23] Introduced NullableModelMultipleChoiceField to allow null filtering without causing introspection issues during database migrations --- netbox/ipam/filters.py | 53 +++++++-------------------- netbox/ipam/forms.py | 2 +- netbox/utilities/filters.py | 72 +++++++++++++++++++++++++++++++------ netbox/utilities/forms.py | 1 - 4 files changed, 74 insertions(+), 54 deletions(-) diff --git a/netbox/ipam/filters.py b/netbox/ipam/filters.py index faae390b3..a998bb2a0 100644 --- a/netbox/ipam/filters.py +++ b/netbox/ipam/filters.py @@ -86,17 +86,17 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet): action='search_by_parent', label='Parent prefix', ) - vrf = NullableModelMultipleChoiceFilter( - name='vrf', - queryset=VRF.objects.all(), - label='VRF', - ) - # Duplicate of `vrf` for backward-compatibility vrf_id = NullableModelMultipleChoiceFilter( name='vrf_id', queryset=VRF.objects.all(), label='VRF', ) + vrf = NullableModelMultipleChoiceFilter( + name='vrf', + queryset=VRF.objects.all(), + to_field_name='rd', + label='VRF (RD)', + ) tenant_id = NullableModelMultipleChoiceFilter( name='tenant', queryset=Tenant.objects.all(), @@ -191,17 +191,17 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet): action='search_by_parent', label='Parent prefix', ) - vrf = NullableModelMultipleChoiceFilter( - name='vrf', - queryset=VRF.objects.all(), - label='VRF', - ) - # Duplicate of `vrf` for backward-compatibility vrf_id = NullableModelMultipleChoiceFilter( name='vrf_id', queryset=VRF.objects.all(), label='VRF', ) + vrf = NullableModelMultipleChoiceFilter( + name='vrf', + queryset=VRF.objects.all(), + to_field_name='rd', + label='VRF (RD)', + ) tenant_id = NullableModelMultipleChoiceFilter( name='tenant', queryset=Tenant.objects.all(), @@ -253,35 +253,6 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet): except AddrFormatError: return queryset.none() - def _vrf(self, queryset, value): - if str(value) == '': - return queryset - try: - vrf_id = int(value) - except ValueError: - return queryset.none() - if vrf_id == 0: - return queryset.filter(vrf__isnull=True) - return queryset.filter(vrf__pk=value) - - def _tenant(self, queryset, value): - if str(value) == '': - return queryset - return queryset.filter( - Q(tenant__slug=value) | - Q(tenant__isnull=True, vrf__tenant__slug=value) - ) - - def _tenant_id(self, queryset, value): - try: - value = int(value) - except ValueError: - return queryset.none() - return queryset.filter( - Q(tenant__pk=value) | - Q(tenant__isnull=True, vrf__tenant__pk=value) - ) - class VLANGroupFilter(django_filters.FilterSet): site_id = django_filters.ModelMultipleChoiceFilter( diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index b2b39177e..6382895f8 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -418,7 +418,7 @@ class IPAddressFilterForm(BootstrapMixin, CustomFieldFilterForm): 'placeholder': 'Prefix', })) family = forms.ChoiceField(required=False, choices=IP_FAMILY_CHOICES, label='Address Family') - vrf = FilterChoiceField(queryset=VRF.objects.annotate(filter_count=Count('ip_addresses')), to_field_name='slug', + vrf = FilterChoiceField(queryset=VRF.objects.annotate(filter_count=Count('ip_addresses')), to_field_name='rd', label='VRF', null_option=(0, 'Global')) tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('ip_addresses')), to_field_name='slug', null_option=(0, 'None')) diff --git a/netbox/utilities/filters.py b/netbox/utilities/filters.py index 861bb11dd..d1dbf39b8 100644 --- a/netbox/utilities/filters.py +++ b/netbox/utilities/filters.py @@ -1,15 +1,65 @@ import django_filters +import itertools +from django import forms from django.db.models import Q +from django.utils.encoding import force_text -class NullableModelMultipleChoiceFilter(django_filters.MultipleChoiceFilter): +class NullableModelMultipleChoiceField(forms.ModelMultipleChoiceField): + """ + This field operates like a normal ModelMultipleChoiceField except that it allows for one additional choice which is + used to represent a value of Null. This is accomplished by creating a new iterator which first yields the null + choice before entering the queryset iterator, and by ignoring the null choice during cleaning. The effect is similar + to defining a MultipleChoiceField with: + + choices = [(0, 'None')] + [(x.id, x) for x in Foo.objects.all()] + + However, the above approach forces immediate evaluation of the queryset, which can cause issues when calculating + database migrations. + """ + iterator = forms.models.ModelChoiceIterator + + def __init__(self, null_value=0, null_label='None', *args, **kwargs): + self.null_value = null_value + self.null_label = null_label + super(NullableModelMultipleChoiceField, self).__init__(*args, **kwargs) + + def _get_choices(self): + if hasattr(self, '_choices'): + return self._choices + # Prepend the null choice to the queryset iterator + return itertools.chain( + [(self.null_value, self.null_label)], + self.iterator(self), + ) + choices = property(_get_choices, forms.ChoiceField._set_choices) + + def clean(self, value): + # Strip all instances of the null value before cleaning + if value is not None: + stripped_value = [x for x in value if x != force_text(self.null_value)] + else: + stripped_value = value + super(NullableModelMultipleChoiceField, self).clean(stripped_value) + return value + + +class NullableModelMultipleChoiceFilter(django_filters.ModelMultipleChoiceFilter): + """ + This class extends ModelMultipleChoiceFilter to accept an additional value which implies "is null". The default + queryset filter argument is: + + .filter(fieldname=value) + + When filtering by the value representing "is null" ('0' by default) the argument is modified to: + + .filter(fieldname__isnull=True) + """ + field_class = NullableModelMultipleChoiceField def __init__(self, *args, **kwargs): - # Convert the queryset to a list of choices prefixed with a "None" option - queryset = kwargs.pop('queryset') - self.to_field_name = kwargs.pop('to_field_name', 'pk') - kwargs['choices'] = [(0, 'None')] + [(getattr(o, self.to_field_name), o) for o in queryset] + self.null_value = kwargs.get('null_value', 0) super(NullableModelMultipleChoiceFilter, self).__init__(*args, **kwargs) def filter(self, qs, value): @@ -24,13 +74,13 @@ class NullableModelMultipleChoiceFilter(django_filters.MultipleChoiceFilter): q = Q() for v in set(value): - # Filtering on NULL - if v == str(0): + # Filtering by "is null" + if v == force_text(self.null_value): arg = {'{}__isnull'.format(self.name): True} - # Filtering on a related field (e.g. slug) - elif self.to_field_name != 'pk': - arg = {'{}__{}'.format(self.name, self.to_field_name): v} - # Filtering on primary key + # Filtering by a related field (e.g. slug) + elif self.field.to_field_name is not None: + arg = {'{}__{}'.format(self.name, self.field.to_field_name): v} + # Filtering by primary key (default) else: arg = {self.name: v} if self.conjoined: diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index 247ea9aed..1df25891b 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -4,7 +4,6 @@ import re from django import forms from django.core.urlresolvers import reverse_lazy -from django.db.models import Count from django.utils.encoding import force_text from django.utils.html import format_html from django.utils.safestring import mark_safe From 75d8852bf7d87ab1ce1d802e0f95bec842fac3bd Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 21 Sep 2016 09:55:57 -0400 Subject: [PATCH 23/23] Release v1.6.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 e9c6ce232..b64f5e4b3 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -12,7 +12,7 @@ except ImportError: "the documentation.") -VERSION = '1.6.1-dev' +VERSION = '1.6.1' # Import local configuration for setting in ['ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY']: