From 115e7d6e505b3da23b733637532aa43003a35f01 Mon Sep 17 00:00:00 2001 From: Ryan Breaker Date: Sun, 15 Oct 2017 18:35:03 -0500 Subject: [PATCH 01/23] Add group and site matching validation to Rack.clean() and testing --- netbox/dcim/models.py | 8 ++++++- netbox/dcim/tests/test_models.py | 38 +++++++++++++++++++++++++++----- 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 336ae0c0b43..e86c702f45b 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -256,8 +256,8 @@ def get_absolute_url(self): def clean(self): - # Validate that Rack is tall enough to house the installed Devices if self.pk: + # Validate that Rack is tall enough to house the installed Devices top_device = Device.objects.filter(rack=self).exclude(position__isnull=True).order_by('-position').first() if top_device: min_height = top_device.position + top_device.device_type.u_height - 1 @@ -267,6 +267,12 @@ def clean(self): min_height ) }) + # Validate that Rack was assigned a group of its same site, if applicable + if self.group: + if self.group.site != self.site: + raise ValidationError({ + 'group': "Rack group must be from the same site, {}.".format(self.site) + }) def save(self, *args, **kwargs): diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index b4101cfd571..9b323c2d16c 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -9,14 +9,29 @@ class RackTestCase(TestCase): def setUp(self): - self.site = Site.objects.create( + self.site1 = Site.objects.create( name='TestSite1', - slug='my-test-site' + slug='test-site-1' + ) + self.site2 = Site.objects.create( + name='TestSite2', + slug='test-site-2' + ) + self.group1 = RackGroup.objects.create( + name='TestGroup1', + slug='test-group-1', + site=self.site1 + ) + self.group2 = RackGroup.objects.create( + name='TestGroup2', + slug='test-group-2', + site=self.site2 ) self.rack = Rack.objects.create( name='TestRack1', facility_id='A101', - site=self.site, + site=self.site1, + group=self.group1, u_height=42 ) self.manufacturer = Manufacturer.objects.create( @@ -57,13 +72,26 @@ def setUp(self): } + def test_rack_group_site(self): + rack_invalid_group = Rack( + name='TestRack2', + facility_id='A102', + site=self.site1, + u_height=42, + group=self.group2 + ) + rack_invalid_group.save() + + with self.assertRaises(ValidationError): + rack_invalid_group.clean() + def test_mount_single_device(self): device1 = Device( name='TestSwitch1', device_type=DeviceType.objects.get(manufacturer__slug='acme', slug='ff2048'), device_role=DeviceRole.objects.get(slug='switch'), - site=self.site, + site=self.site1, rack=self.rack, position=10, face=RACK_FACE_REAR, @@ -92,7 +120,7 @@ def test_mount_zero_ru(self): name='TestPDU', device_role=self.role.get('PDU'), device_type=self.device_type.get('cc5000'), - site=self.site, + site=self.site1, rack=self.rack, position=None, face=None, From 937faaf149e21fb2251d48fc942e2d2cb41c5d79 Mon Sep 17 00:00:00 2001 From: Ryan Breaker Date: Sun, 15 Oct 2017 19:03:28 -0500 Subject: [PATCH 02/23] Add test for validation of device within rack height. --- netbox/dcim/tests/test_models.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index 9b323c2d16c..5b2cdbd51cd 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -72,7 +72,32 @@ def setUp(self): } + def test_rack_device_outside_height(self): + + rack1 = Rack( + name='TestRack2', + facility_id='A102', + site=self.site1, + u_height=42 + ) + rack1.save() + + device1 = Device( + name='TestSwitch1', + device_type=DeviceType.objects.get(manufacturer__slug='acme', slug='ff2048'), + device_role=DeviceRole.objects.get(slug='switch'), + site=self.site1, + rack=rack1, + position=43, + face=RACK_FACE_FRONT, + ) + device1.save() + + with self.assertRaises(ValidationError): + rack1.clean() + def test_rack_group_site(self): + rack_invalid_group = Rack( name='TestRack2', facility_id='A102', From a91fcbb3106b66b7b2f12424816a60447a90db93 Mon Sep 17 00:00:00 2001 From: Ryan Breaker Date: Mon, 16 Oct 2017 14:59:39 -0500 Subject: [PATCH 03/23] Added virtualization fixture for `loaddata initial_data` --- .../virtualization/fixtures/initial_data.json | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 netbox/virtualization/fixtures/initial_data.json diff --git a/netbox/virtualization/fixtures/initial_data.json b/netbox/virtualization/fixtures/initial_data.json new file mode 100644 index 00000000000..0d98533e76b --- /dev/null +++ b/netbox/virtualization/fixtures/initial_data.json @@ -0,0 +1,61 @@ +[ +{ + "model": "virtualization.clustertype", + "pk": 1, + "fields": { + "name": "Public Cloud", + "slug": "public-cloud" + } +}, +{ + "model": "virtualization.clustertype", + "pk": 2, + "fields": { + "name": "vSphere", + "slug": "vsphere" + } +}, +{ + "model": "virtualization.clustertype", + "pk": 3, + "fields": { + "name": "libvirt", + "slug": "libvirt" + } +}, +{ + "model": "virtualization.clustertype", + "pk": 4, + "fields": { + "name": "LXD", + "slug": "lxd" + } +}, +{ + "model": "virtualization.clustertype", + "pk": 5, + "fields": { + "name": "Docker", + "slug": "docker" + } +}, +{ + "model": "virtualization.clustergroup", + "pk": 1, + "fields": { + "name": "VM Host", + "slug": "vm-host" + } +}, +{ + "model": "virtualization.cluster", + "pk": 1, + "fields": { + "name": "Amazon EC2", + "type": 1, + "group": 1, + "created": "2016-08-01", + "last_updated": "2016-08-01T15:22:42.289Z" + } +} +] From b63efdd80b81a23f8dc742baeca7ffda1928ca97 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 17 Oct 2017 11:25:12 -0400 Subject: [PATCH 04/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 934da12809a..be6311d8b7f 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -13,7 +13,7 @@ ) -VERSION = '2.2.2' +VERSION = '2.2.3-dev' BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) From 272325ff051d88219e9af16eaf50cab846366085 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 18 Oct 2017 12:31:58 -0400 Subject: [PATCH 05/23] Fixes #1618: Allow bulk deletion of all virtual machines --- netbox/virtualization/views.py | 1 + 1 file changed, 1 insertion(+) diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index ad49b7dec14..00f59f8d4ae 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -314,6 +314,7 @@ class VirtualMachineBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'virtualization.delete_virtualmachine' cls = VirtualMachine queryset = VirtualMachine.objects.select_related('cluster', 'tenant') + filter = filters.VirtualMachineFilter table = tables.VirtualMachineTable default_return_url = 'virtualization:virtualmachine_list' From 6ae62094577c996010e34a466da1dae4fddba9ed Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 18 Oct 2017 13:01:49 -0400 Subject: [PATCH 06/23] Fixes #1619: Correct text-based filtering of IP network and address fields --- netbox/ipam/lookups.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/netbox/ipam/lookups.py b/netbox/ipam/lookups.py index ef5cf8327ef..59cd91ed850 100644 --- a/netbox/ipam/lookups.py +++ b/netbox/ipam/lookups.py @@ -1,7 +1,7 @@ from __future__ import unicode_literals from django.db.models import Lookup, Transform, IntegerField -from django.db.models.lookups import BuiltinLookup +from django.db.models import lookups class NetFieldDecoratorMixin(object): @@ -13,27 +13,27 @@ def process_lhs(self, qn, connection, lhs=None): return lhs_string, lhs_params -class EndsWith(NetFieldDecoratorMixin, BuiltinLookup): +class EndsWith(NetFieldDecoratorMixin, lookups.EndsWith): lookup_name = 'endswith' -class IEndsWith(NetFieldDecoratorMixin, BuiltinLookup): +class IEndsWith(NetFieldDecoratorMixin, lookups.IEndsWith): lookup_name = 'iendswith' -class StartsWith(NetFieldDecoratorMixin, BuiltinLookup): +class StartsWith(NetFieldDecoratorMixin, lookups.StartsWith): lookup_name = 'startswith' -class IStartsWith(NetFieldDecoratorMixin, BuiltinLookup): +class IStartsWith(NetFieldDecoratorMixin, lookups.IStartsWith): lookup_name = 'istartswith' -class Regex(NetFieldDecoratorMixin, BuiltinLookup): +class Regex(NetFieldDecoratorMixin, lookups.Regex): lookup_name = 'regex' -class IRegex(NetFieldDecoratorMixin, BuiltinLookup): +class IRegex(NetFieldDecoratorMixin, lookups.IRegex): lookup_name = 'iregex' From 515645bb4dcfe204a3343de790e0c0f80892986d Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 18 Oct 2017 14:29:56 -0400 Subject: [PATCH 07/23] Fixes #1620: Loosen IP address search filter to match all IPs that start with the given string --- netbox/ipam/filters.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/netbox/ipam/filters.py b/netbox/ipam/filters.py index 05ab873a3e0..b8996c74b24 100644 --- a/netbox/ipam/filters.py +++ b/netbox/ipam/filters.py @@ -267,12 +267,10 @@ class Meta: def search(self, queryset, name, value): if not value.strip(): return queryset - qs_filter = Q(description__icontains=value) - try: - ipaddress = str(IPNetwork(value.strip())) - qs_filter |= Q(address__net_host=ipaddress) - except (AddrFormatError, ValueError): - pass + qs_filter = ( + Q(description__icontains=value) | + Q(address__istartswith=value) + ) return queryset.filter(qs_filter) def search_by_parent(self, queryset, name, value): From 81ca6f7cba8232d45c4cb2744e2e9bd33734f4d7 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 19 Oct 2017 17:12:28 -0400 Subject: [PATCH 08/23] #1621: Allow for loose matching of short-form IOS interface names when validating LLDP neighbors --- .../templates/dcim/device_lldp_neighbors.html | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/netbox/templates/dcim/device_lldp_neighbors.html b/netbox/templates/dcim/device_lldp_neighbors.html index 99c1f64655b..c79cf895556 100644 --- a/netbox/templates/dcim/device_lldp_neighbors.html +++ b/netbox/templates/dcim/device_lldp_neighbors.html @@ -54,15 +54,27 @@ $.each(json['get_lldp_neighbors'], function(iface, neighbors) { var neighbor = neighbors[0]; var row = $('#' + iface.split(".")[0].replace(/(\/)/g, "\\$1")); + + // Glean configured hostnames/interfaces from the DOM var configured_device = row.children('td.configured_device').attr('data'); var configured_interface = row.children('td.configured_interface').attr('data'); + if (configured_interface) { + // Match long-form IOS names against short ones (e.g. Gi0/1 == GigabitEthernet0/1). + configured_interface = configured_interface.replace(/^([A-Z][a-z])[^0-9]*([0-9\/]+)$/, "$1$2"); + } + + // Clean up hostnames/interfaces learned via LLDP + var lldp_device = neighbor['hostname'].split(".")[0]; // Strip off any trailing domain name + var lldp_interface = neighbor['port'].split(".")[0]; // Strip off any trailing subinterface ID + // Add LLDP neighbors to table - row.children('td.device').html(neighbor['hostname']); - row.children('td.interface').html(neighbor['port']); + row.children('td.device').html(lldp_device); + row.children('td.interface').html(lldp_interface); + // Apply colors to rows - if (!configured_device && neighbor['hostname']) { + if (!configured_device && lldp_device) { row.addClass('info'); - } else if (configured_device == neighbor['hostname'] && configured_interface == neighbor['port'].split(".")[0]) { + } else if (configured_device == lldp_device && configured_interface == lldp_interface) { row.addClass('success'); } else { row.addClass('danger'); From 060f7a7191e94320a1c7bde31d822515a124bbeb Mon Sep 17 00:00:00 2001 From: Ryan Breaker Date: Thu, 19 Oct 2017 20:18:09 -0500 Subject: [PATCH 09/23] Add Hyper-V and Azure to Virtualization initial_data --- .../virtualization/fixtures/initial_data.json | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/netbox/virtualization/fixtures/initial_data.json b/netbox/virtualization/fixtures/initial_data.json index 0d98533e76b..19e36deb3b7 100644 --- a/netbox/virtualization/fixtures/initial_data.json +++ b/netbox/virtualization/fixtures/initial_data.json @@ -18,6 +18,14 @@ { "model": "virtualization.clustertype", "pk": 3, + "fields": { + "name": "Hyper-V", + "slug": "hyper-v" + } +}, +{ + "model": "virtualization.clustertype", + "pk": 4, "fields": { "name": "libvirt", "slug": "libvirt" @@ -25,7 +33,7 @@ }, { "model": "virtualization.clustertype", - "pk": 4, + "pk": 5, "fields": { "name": "LXD", "slug": "lxd" @@ -33,7 +41,7 @@ }, { "model": "virtualization.clustertype", - "pk": 5, + "pk": 6, "fields": { "name": "Docker", "slug": "docker" @@ -57,5 +65,16 @@ "created": "2016-08-01", "last_updated": "2016-08-01T15:22:42.289Z" } +}, +{ + "model": "virtualization.cluster", + "pk": 2, + "fields": { + "name": "Microsoft Azure", + "type": 1, + "group": 1, + "created": "2016-08-01", + "last_updated": "2016-08-01T15:22:42.289Z" + } } ] From 85347d9675473a76d1a0829d1a7610bc3ecbd890 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 20 Oct 2017 16:27:19 -0400 Subject: [PATCH 10/23] Closes #999: Display devices on which circuits are terminated in circuits list --- netbox/circuits/tables.py | 29 ++++++++++++++++++++--------- netbox/circuits/views.py | 6 +++++- 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/netbox/circuits/tables.py b/netbox/circuits/tables.py index 58775b378e9..bf628e6f802 100644 --- a/netbox/circuits/tables.py +++ b/netbox/circuits/tables.py @@ -3,6 +3,8 @@ import django_tables2 as tables from django_tables2.utils import Accessor +from django.utils.safestring import mark_safe + from utilities.tables import BaseTable, ToggleColumn from .models import Circuit, CircuitType, Provider @@ -14,6 +16,21 @@ """ +class CircuitTerminationColumn(tables.Column): + + def render(self, value): + if value.interface: + return mark_safe('{}'.format( + value.interface.device.get_absolute_url(), + value.site, + value.interface.device + )) + return mark_safe('{}'.format( + value.site.get_absolute_url(), + value.site + )) + + # # Providers # @@ -61,15 +78,9 @@ class CircuitTable(BaseTable): cid = tables.LinkColumn(verbose_name='ID') provider = tables.LinkColumn('circuits:provider', args=[Accessor('provider.slug')]) tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')]) - a_side = tables.LinkColumn( - 'dcim:site', accessor=Accessor('termination_a.site'), orderable=False, - args=[Accessor('termination_a.site.slug')] - ) - z_side = tables.LinkColumn( - 'dcim:site', accessor=Accessor('termination_z.site'), orderable=False, - args=[Accessor('termination_z.site.slug')] - ) + termination_a = CircuitTerminationColumn(orderable=False, verbose_name='A Side') + termination_z = CircuitTerminationColumn(orderable=False, verbose_name='Z Side') class Meta(BaseTable.Meta): model = Circuit - fields = ('pk', 'cid', 'type', 'provider', 'tenant', 'a_side', 'z_side', 'description') + fields = ('pk', 'cid', 'type', 'provider', 'tenant', 'termination_a', 'termination_z', 'description') diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index 4a49e713a49..d8910ba293f 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -134,7 +134,11 @@ class CircuitTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # class CircuitListView(ObjectListView): - queryset = Circuit.objects.select_related('provider', 'type', 'tenant').prefetch_related('terminations__site') + queryset = Circuit.objects.select_related( + 'provider', 'type', 'tenant' + ).prefetch_related( + 'terminations__site', 'terminations__interface__device' + ) filter = filters.CircuitFilter filter_form = forms.CircuitFilterForm table = tables.CircuitTable From a0b93bb4dfb0381d9ca717f4fa1e0773daad3ff1 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 20 Oct 2017 16:39:13 -0400 Subject: [PATCH 11/23] Fixes #1513: Correct filtering of custom field choices --- netbox/extras/filters.py | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/netbox/extras/filters.py b/netbox/extras/filters.py index 6bd5f3737a4..474410e65a6 100644 --- a/netbox/extras/filters.py +++ b/netbox/extras/filters.py @@ -19,17 +19,28 @@ def __init__(self, cf_type, *args, **kwargs): 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 + + # Selection fields get special treatment (values must be integers) + if self.cf_type == CF_TYPE_SELECT: + try: + # Treat 0 as None + if int(value) == 0: + return queryset.exclude( + custom_field_values__field__name=self.name, + ) + # Match on exact CustomFieldChoice PK + else: + return queryset.filter( + custom_field_values__field__name=self.name, + custom_field_values__serialized_value=value, + ) + except ValueError: + return queryset.none() + return queryset.filter( custom_field_values__field__name=self.name, custom_field_values__serialized_value__icontains=value, From 14e5f89febea452c20ee62adf0d188e724963215 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 23 Oct 2017 13:15:34 -0400 Subject: [PATCH 12/23] Fixes #1624: Add VM count to device roles table --- netbox/dcim/tables.py | 3 ++- netbox/dcim/views.py | 5 ++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py index 2a012738982..5f41b9dfa77 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -362,6 +362,7 @@ class DeviceRoleTable(BaseTable): pk = ToggleColumn() name = tables.LinkColumn(verbose_name='Name') device_count = tables.Column(verbose_name='Devices') + vm_count = tables.Column(verbose_name='VMs') color = tables.TemplateColumn(COLOR_LABEL, verbose_name='Label') slug = tables.Column(verbose_name='Slug') actions = tables.TemplateColumn(template_code=DEVICEROLE_ACTIONS, attrs={'td': {'class': 'text-right'}}, @@ -369,7 +370,7 @@ class DeviceRoleTable(BaseTable): class Meta(BaseTable.Meta): model = DeviceRole - fields = ('pk', 'name', 'device_count', 'color', 'vm_role', 'slug', 'actions') + fields = ('pk', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'slug', 'actions') # diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 13710c310f4..268682f67e9 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -700,7 +700,10 @@ class DeviceBayTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # class DeviceRoleListView(ObjectListView): - queryset = DeviceRole.objects.annotate(device_count=Count('devices')) + queryset = DeviceRole.objects.annotate( + device_count=Count('devices', distinct=True), + vm_count=Count('virtual_machines', distinct=True) + ) table = tables.DeviceRoleTable template_name = 'dcim/devicerole_list.html' From 54472b3806b429f6e60d2ccf95ed175f659a73d8 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 23 Oct 2017 13:17:51 -0400 Subject: [PATCH 13/23] Fixes #1634: Cluster should not be a required field when importing child devices --- netbox/dcim/forms.py | 1 + 1 file changed, 1 insertion(+) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index f0b0f6d52ad..c3a545f7cc3 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -953,6 +953,7 @@ class ChildDeviceCSVForm(BaseDeviceCSVForm): cluster = forms.ModelChoiceField( queryset=Cluster.objects.all(), to_field_name='name', + required=False, help_text='Virtualization cluster', error_messages={ 'invalid_choice': 'Invalid cluster name.', From 02c278f3938611e15f526936f3bfaf59a90edf8a Mon Sep 17 00:00:00 2001 From: Ryan Breaker Date: Thu, 26 Oct 2017 10:05:18 -0500 Subject: [PATCH 14/23] Add Digital Ocean as an initial cluster --- netbox/virtualization/fixtures/initial_data.json | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/netbox/virtualization/fixtures/initial_data.json b/netbox/virtualization/fixtures/initial_data.json index 19e36deb3b7..6f57baffa82 100644 --- a/netbox/virtualization/fixtures/initial_data.json +++ b/netbox/virtualization/fixtures/initial_data.json @@ -59,7 +59,7 @@ "model": "virtualization.cluster", "pk": 1, "fields": { - "name": "Amazon EC2", + "name": "Digital Ocean", "type": 1, "group": 1, "created": "2016-08-01", @@ -69,6 +69,17 @@ { "model": "virtualization.cluster", "pk": 2, + "fields": { + "name": "Amazon EC2", + "type": 1, + "group": 1, + "created": "2016-08-01", + "last_updated": "2016-08-01T15:22:42.289Z" + } +}, +{ + "model": "virtualization.cluster", + "pk": 3, "fields": { "name": "Microsoft Azure", "type": 1, From 1b5aa67f5dc7c425cef1223fc039e66149f05fa1 Mon Sep 17 00:00:00 2001 From: Tom Strickx Date: Thu, 26 Oct 2017 09:24:39 -0700 Subject: [PATCH 15/23] Remove explicit reference to the `name` field Remove the explicit usage of the `name` field, and let the __str__ method of the ConsoleServerPort class handle the display --- netbox/templates/dcim/inc/consoleserverport.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/templates/dcim/inc/consoleserverport.html b/netbox/templates/dcim/inc/consoleserverport.html index e400098817b..69dbfc0b80b 100644 --- a/netbox/templates/dcim/inc/consoleserverport.html +++ b/netbox/templates/dcim/inc/consoleserverport.html @@ -5,7 +5,7 @@ {% endif %} - {{ csp.name }} + {{ csp }} {% if csp.connected_console %} From 5be4b0c4fd1ddcde2c2688bfe49213b3472fcb4f Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 27 Oct 2017 10:02:27 -0400 Subject: [PATCH 16/23] Closes #1631: Added a post_run method to the Report class --- docs/miscellaneous/reports.md | 2 ++ netbox/extras/reports.py | 9 +++++++++ 2 files changed, 11 insertions(+) diff --git a/docs/miscellaneous/reports.md b/docs/miscellaneous/reports.md index aae258f004b..daffa72aee0 100644 --- a/docs/miscellaneous/reports.md +++ b/docs/miscellaneous/reports.md @@ -94,6 +94,8 @@ The following methods are available to log results within a report: The recording of one or more failure messages will automatically flag a report as failed. It is advised to log a success for each object that is evaluated so that the results will reflect how many objects are being reported on. (The inclusion of a log message is optional for successes.) Messages recorded with `log()` will appear in a report's results but are not associated with a particular object or status. +To perform additional tasks, such as sending an email or calling a webhook, after a report has been run, extend the `post_run()` method. The status of the report is available as `self.failed` and the results object is `self.result`. + Once you have created a report, it will appear in the reports list. Initially, reports will have no results associated with them. To generate results, run the report. ## Running Reports diff --git a/netbox/extras/reports.py b/netbox/extras/reports.py index 409aaaf30bd..d17b811cb9e 100644 --- a/netbox/extras/reports.py +++ b/netbox/extras/reports.py @@ -177,3 +177,12 @@ def run(self): result = ReportResult(report=self.full_name, failed=self.failed, data=self._results) result.save() self.result = result + + # Perform any post-run tasks + self.post_run() + + def post_run(self): + """ + Extend this method to include any tasks which should execute after the report has been run. + """ + pass From 9c0b414676d54c67bc56617c513e81d556f74d66 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 27 Oct 2017 10:31:54 -0400 Subject: [PATCH 17/23] Fixed vertical heigh of checboxes in tables --- netbox/project-static/css/base.css | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/netbox/project-static/css/base.css b/netbox/project-static/css/base.css index b65f188d8a1..fb9687d3a01 100644 --- a/netbox/project-static/css/base.css +++ b/netbox/project-static/css/base.css @@ -106,9 +106,14 @@ label { label.required { font-weight: bold; } +input[name="pk"] { + margin-top: 0; +} /* Tables */ -th.pk, td.pk { +.table > tbody > tr > th.pk, .table > tbody > tr > td.pk { + padding-bottom: 6px; + padding-top: 10px; width: 30px; } tfoot td { From 0a04bb110a2df199bc8c006c4622e84cfaecb0c4 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 27 Oct 2017 10:33:58 -0400 Subject: [PATCH 18/23] Fixes #1603: Hide selection checkboxes for tables with no available actions --- netbox/dcim/views.py | 12 ++++++------ netbox/ipam/views.py | 6 +++--- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 268682f67e9..e0ab7f1f2bb 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -517,12 +517,12 @@ def get(self, request, pk): show_header=False ) if request.user.has_perm('dcim.change_devicetype'): - consoleport_table.base_columns['pk'].visible = True - consoleserverport_table.base_columns['pk'].visible = True - powerport_table.base_columns['pk'].visible = True - poweroutlet_table.base_columns['pk'].visible = True - interface_table.base_columns['pk'].visible = True - devicebay_table.base_columns['pk'].visible = True + consoleport_table.columns.show('pk') + consoleserverport_table.columns.show('pk') + powerport_table.columns.show('pk') + poweroutlet_table.columns.show('pk') + interface_table.columns.show('pk') + devicebay_table.columns.show('pk') return render(request, 'dcim/devicetype.html', { 'devicetype': devicetype, diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 7cf24614fea..48e7e8478d8 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -325,7 +325,7 @@ def get(self, request, pk): prefix_table = tables.PrefixDetailTable(child_prefixes) if request.user.has_perm('ipam.change_prefix') or request.user.has_perm('ipam.delete_prefix'): - prefix_table.base_columns['pk'].visible = True + prefix_table.columns.show('pk') paginate = { 'klass': EnhancedPaginator, @@ -495,7 +495,7 @@ def get(self, request, pk): child_prefixes = add_available_prefixes(prefix.prefix, child_prefixes) child_prefix_table = tables.PrefixDetailTable(child_prefixes) if request.user.has_perm('ipam.change_prefix') or request.user.has_perm('ipam.delete_prefix'): - child_prefix_table.base_columns['pk'].visible = True + child_prefix_table.columns.show('pk') paginate = { 'klass': EnhancedPaginator, @@ -538,7 +538,7 @@ def get(self, request, pk): ip_table = tables.IPAddressTable(ipaddresses) if request.user.has_perm('ipam.change_ipaddress') or request.user.has_perm('ipam.delete_ipaddress'): - ip_table.base_columns['pk'].visible = True + ip_table.columns.show('pk') paginate = { 'klass': EnhancedPaginator, From 4668149943b6749cc2c1489c1c926c81f1478254 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 30 Oct 2017 17:20:22 -0400 Subject: [PATCH 19/23] Fixes #1649: Correct fitlering on null values (e.g. ?tenant_id=0) for django-filters v1.1.0+ --- netbox/circuits/filters.py | 6 ++-- netbox/dcim/filters.py | 44 +++++++++++++-------------- netbox/ipam/filters.py | 52 ++++++++++++++++---------------- netbox/netbox/settings.py | 4 +++ netbox/tenancy/filters.py | 6 ++-- netbox/utilities/filters.py | 49 ------------------------------ netbox/virtualization/filters.py | 22 +++++++------- requirements.txt | 2 +- 8 files changed, 70 insertions(+), 115 deletions(-) diff --git a/netbox/circuits/filters.py b/netbox/circuits/filters.py index 8a1b01a89d3..ea383145578 100644 --- a/netbox/circuits/filters.py +++ b/netbox/circuits/filters.py @@ -7,7 +7,7 @@ from dcim.models import Site from extras.filters import CustomFieldFilterSet from tenancy.models import Tenant -from utilities.filters import NullableModelMultipleChoiceFilter, NumericInFilter +from utilities.filters import NumericInFilter from .models import Provider, Circuit, CircuitTermination, CircuitType @@ -78,11 +78,11 @@ class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet): to_field_name='slug', label='Circuit type (slug)', ) - tenant_id = NullableModelMultipleChoiceFilter( + tenant_id = django_filters.ModelMultipleChoiceFilter( queryset=Tenant.objects.all(), label='Tenant (ID)', ) - tenant = NullableModelMultipleChoiceFilter( + tenant = django_filters.ModelMultipleChoiceFilter( name='tenant', queryset=Tenant.objects.all(), to_field_name='slug', diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index 6e0c6fa3d03..6c40953650b 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -9,7 +9,7 @@ from extras.filters import CustomFieldFilterSet from tenancy.models import Tenant -from utilities.filters import NullableCharFieldFilter, NullableModelMultipleChoiceFilter, NumericInFilter +from utilities.filters import NullableCharFieldFilter, NumericInFilter from virtualization.models import Cluster from .models import ( ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, @@ -21,11 +21,11 @@ class RegionFilter(django_filters.FilterSet): - parent_id = NullableModelMultipleChoiceFilter( + parent_id = django_filters.ModelMultipleChoiceFilter( queryset=Region.objects.all(), label='Parent region (ID)', ) - parent = NullableModelMultipleChoiceFilter( + parent = django_filters.ModelMultipleChoiceFilter( queryset=Region.objects.all(), to_field_name='slug', label='Parent region (slug)', @@ -42,20 +42,20 @@ class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet): method='search', label='Search', ) - region_id = NullableModelMultipleChoiceFilter( + region_id = django_filters.ModelMultipleChoiceFilter( queryset=Region.objects.all(), label='Region (ID)', ) - region = NullableModelMultipleChoiceFilter( + region = django_filters.ModelMultipleChoiceFilter( queryset=Region.objects.all(), to_field_name='slug', label='Region (slug)', ) - tenant_id = NullableModelMultipleChoiceFilter( + tenant_id = django_filters.ModelMultipleChoiceFilter( queryset=Tenant.objects.all(), label='Tenant (ID)', ) - tenant = NullableModelMultipleChoiceFilter( + tenant = django_filters.ModelMultipleChoiceFilter( queryset=Tenant.objects.all(), to_field_name='slug', label='Tenant (slug)', @@ -126,31 +126,31 @@ class RackFilter(CustomFieldFilterSet, django_filters.FilterSet): to_field_name='slug', label='Site (slug)', ) - group_id = NullableModelMultipleChoiceFilter( + group_id = django_filters.ModelMultipleChoiceFilter( queryset=RackGroup.objects.all(), label='Group (ID)', ) - group = NullableModelMultipleChoiceFilter( + group = django_filters.ModelMultipleChoiceFilter( name='group', queryset=RackGroup.objects.all(), to_field_name='slug', label='Group', ) - tenant_id = NullableModelMultipleChoiceFilter( + tenant_id = django_filters.ModelMultipleChoiceFilter( queryset=Tenant.objects.all(), label='Tenant (ID)', ) - tenant = NullableModelMultipleChoiceFilter( + tenant = django_filters.ModelMultipleChoiceFilter( name='tenant', queryset=Tenant.objects.all(), to_field_name='slug', label='Tenant (slug)', ) - role_id = NullableModelMultipleChoiceFilter( + role_id = django_filters.ModelMultipleChoiceFilter( queryset=RackRole.objects.all(), label='Role (ID)', ) - role = NullableModelMultipleChoiceFilter( + role = django_filters.ModelMultipleChoiceFilter( name='role', queryset=RackRole.objects.all(), to_field_name='slug', @@ -193,12 +193,12 @@ class RackReservationFilter(django_filters.FilterSet): to_field_name='slug', label='Site (slug)', ) - group_id = NullableModelMultipleChoiceFilter( + group_id = django_filters.ModelMultipleChoiceFilter( name='rack__group', queryset=RackGroup.objects.all(), label='Group (ID)', ) - group = NullableModelMultipleChoiceFilter( + group = django_filters.ModelMultipleChoiceFilter( name='rack__group', queryset=RackGroup.objects.all(), to_field_name='slug', @@ -368,21 +368,21 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet): to_field_name='slug', label='Role (slug)', ) - tenant_id = NullableModelMultipleChoiceFilter( + tenant_id = django_filters.ModelMultipleChoiceFilter( queryset=Tenant.objects.all(), label='Tenant (ID)', ) - tenant = NullableModelMultipleChoiceFilter( + tenant = django_filters.ModelMultipleChoiceFilter( name='tenant', queryset=Tenant.objects.all(), to_field_name='slug', label='Tenant (slug)', ) - platform_id = NullableModelMultipleChoiceFilter( + platform_id = django_filters.ModelMultipleChoiceFilter( queryset=Platform.objects.all(), label='Platform (ID)', ) - platform = NullableModelMultipleChoiceFilter( + platform = django_filters.ModelMultipleChoiceFilter( name='platform', queryset=Platform.objects.all(), to_field_name='slug', @@ -405,12 +405,12 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet): queryset=RackGroup.objects.all(), label='Rack group (ID)', ) - rack_id = NullableModelMultipleChoiceFilter( + rack_id = django_filters.ModelMultipleChoiceFilter( name='rack', queryset=Rack.objects.all(), label='Rack (ID)', ) - cluster_id = NullableModelMultipleChoiceFilter( + cluster_id = django_filters.ModelMultipleChoiceFilter( queryset=Cluster.objects.all(), label='VM cluster (ID)', ) @@ -595,7 +595,7 @@ class Meta: class InventoryItemFilter(DeviceComponentFilterSet): - parent_id = NullableModelMultipleChoiceFilter( + parent_id = django_filters.ModelMultipleChoiceFilter( queryset=InventoryItem.objects.all(), label='Parent inventory item (ID)', ) diff --git a/netbox/ipam/filters.py b/netbox/ipam/filters.py index b8996c74b24..d6ce6b98768 100644 --- a/netbox/ipam/filters.py +++ b/netbox/ipam/filters.py @@ -9,7 +9,7 @@ from dcim.models import Site, Device, Interface from extras.filters import CustomFieldFilterSet from tenancy.models import Tenant -from utilities.filters import NullableModelMultipleChoiceFilter, NumericInFilter +from utilities.filters import NumericInFilter from virtualization.models import VirtualMachine from .models import ( Aggregate, IPAddress, IPADDRESS_ROLE_CHOICES, IPADDRESS_STATUS_CHOICES, Prefix, PREFIX_STATUS_CHOICES, RIR, Role, @@ -23,11 +23,11 @@ class VRFFilter(CustomFieldFilterSet, django_filters.FilterSet): method='search', label='Search', ) - tenant_id = NullableModelMultipleChoiceFilter( + tenant_id = django_filters.ModelMultipleChoiceFilter( queryset=Tenant.objects.all(), label='Tenant (ID)', ) - tenant = NullableModelMultipleChoiceFilter( + tenant = django_filters.ModelMultipleChoiceFilter( name='tenant', queryset=Tenant.objects.all(), to_field_name='slug', @@ -110,37 +110,37 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet): method='filter_mask_length', label='Mask length', ) - vrf_id = NullableModelMultipleChoiceFilter( + vrf_id = django_filters.ModelMultipleChoiceFilter( queryset=VRF.objects.all(), label='VRF', ) - vrf = NullableModelMultipleChoiceFilter( + vrf = django_filters.ModelMultipleChoiceFilter( name='vrf', queryset=VRF.objects.all(), to_field_name='rd', label='VRF (RD)', ) - tenant_id = NullableModelMultipleChoiceFilter( + tenant_id = django_filters.ModelMultipleChoiceFilter( queryset=Tenant.objects.all(), label='Tenant (ID)', ) - tenant = NullableModelMultipleChoiceFilter( + tenant = django_filters.ModelMultipleChoiceFilter( name='tenant', queryset=Tenant.objects.all(), to_field_name='slug', label='Tenant (slug)', ) - site_id = NullableModelMultipleChoiceFilter( + site_id = django_filters.ModelMultipleChoiceFilter( queryset=Site.objects.all(), label='Site (ID)', ) - site = NullableModelMultipleChoiceFilter( + site = django_filters.ModelMultipleChoiceFilter( name='site', queryset=Site.objects.all(), to_field_name='slug', label='Site (slug)', ) - vlan_id = NullableModelMultipleChoiceFilter( + vlan_id = django_filters.ModelMultipleChoiceFilter( queryset=VLAN.objects.all(), label='VLAN (ID)', ) @@ -148,11 +148,11 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet): name='vlan__vid', label='VLAN number (1-4095)', ) - role_id = NullableModelMultipleChoiceFilter( + role_id = django_filters.ModelMultipleChoiceFilter( queryset=Role.objects.all(), label='Role (ID)', ) - role = NullableModelMultipleChoiceFilter( + role = django_filters.ModelMultipleChoiceFilter( name='role', queryset=Role.objects.all(), to_field_name='slug', @@ -207,21 +207,21 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet): method='filter_mask_length', label='Mask length', ) - vrf_id = NullableModelMultipleChoiceFilter( + vrf_id = django_filters.ModelMultipleChoiceFilter( queryset=VRF.objects.all(), label='VRF', ) - vrf = NullableModelMultipleChoiceFilter( + vrf = django_filters.ModelMultipleChoiceFilter( name='vrf', queryset=VRF.objects.all(), to_field_name='rd', label='VRF (RD)', ) - tenant_id = NullableModelMultipleChoiceFilter( + tenant_id = django_filters.ModelMultipleChoiceFilter( queryset=Tenant.objects.all(), label='Tenant (ID)', ) - tenant = NullableModelMultipleChoiceFilter( + tenant = django_filters.ModelMultipleChoiceFilter( name='tenant', queryset=Tenant.objects.all(), to_field_name='slug', @@ -290,11 +290,11 @@ def filter_mask_length(self, queryset, name, value): class VLANGroupFilter(django_filters.FilterSet): - site_id = NullableModelMultipleChoiceFilter( + site_id = django_filters.ModelMultipleChoiceFilter( queryset=Site.objects.all(), label='Site (ID)', ) - site = NullableModelMultipleChoiceFilter( + site = django_filters.ModelMultipleChoiceFilter( name='site', queryset=Site.objects.all(), to_field_name='slug', @@ -312,41 +312,41 @@ class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet): method='search', label='Search', ) - site_id = NullableModelMultipleChoiceFilter( + site_id = django_filters.ModelMultipleChoiceFilter( queryset=Site.objects.all(), label='Site (ID)', ) - site = NullableModelMultipleChoiceFilter( + site = django_filters.ModelMultipleChoiceFilter( name='site', queryset=Site.objects.all(), to_field_name='slug', label='Site (slug)', ) - group_id = NullableModelMultipleChoiceFilter( + group_id = django_filters.ModelMultipleChoiceFilter( queryset=VLANGroup.objects.all(), label='Group (ID)', ) - group = NullableModelMultipleChoiceFilter( + group = django_filters.ModelMultipleChoiceFilter( name='group', queryset=VLANGroup.objects.all(), to_field_name='slug', label='Group', ) - tenant_id = NullableModelMultipleChoiceFilter( + tenant_id = django_filters.ModelMultipleChoiceFilter( queryset=Tenant.objects.all(), label='Tenant (ID)', ) - tenant = NullableModelMultipleChoiceFilter( + tenant = django_filters.ModelMultipleChoiceFilter( name='tenant', queryset=Tenant.objects.all(), to_field_name='slug', label='Tenant (slug)', ) - role_id = NullableModelMultipleChoiceFilter( + role_id = django_filters.ModelMultipleChoiceFilter( queryset=Role.objects.all(), label='Role (ID)', ) - role = NullableModelMultipleChoiceFilter( + role = django_filters.ModelMultipleChoiceFilter( name='role', queryset=Role.objects.all(), to_field_name='slug', diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index be6311d8b7f..572d5a40a5c 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -206,6 +206,10 @@ # Secrets SECRETS_MIN_PUBKEY_SIZE = 2048 +# Django filters +FILTERS_NULL_CHOICE_LABEL = 'None' +FILTERS_NULL_CHOICE_VALUE = '0' # Must be a string + # Django REST framework (API) REST_FRAMEWORK_VERSION = VERSION[0:3] # Use major.minor as API version REST_FRAMEWORK = { diff --git a/netbox/tenancy/filters.py b/netbox/tenancy/filters.py index 630e936e4b6..275d998e22a 100644 --- a/netbox/tenancy/filters.py +++ b/netbox/tenancy/filters.py @@ -5,7 +5,7 @@ from django.db.models import Q from extras.filters import CustomFieldFilterSet -from utilities.filters import NullableModelMultipleChoiceFilter, NumericInFilter +from utilities.filters import NumericInFilter from .models import Tenant, TenantGroup @@ -22,11 +22,11 @@ class TenantFilter(CustomFieldFilterSet, django_filters.FilterSet): method='search', label='Search', ) - group_id = NullableModelMultipleChoiceFilter( + group_id = django_filters.ModelMultipleChoiceFilter( queryset=TenantGroup.objects.all(), label='Group (ID)', ) - group = NullableModelMultipleChoiceFilter( + group = django_filters.ModelMultipleChoiceFilter( name='group', queryset=TenantGroup.objects.all(), to_field_name='slug', diff --git a/netbox/utilities/filters.py b/netbox/utilities/filters.py index 5bd635a4627..de671cd0af7 100644 --- a/netbox/utilities/filters.py +++ b/netbox/utilities/filters.py @@ -4,7 +4,6 @@ import itertools from django import forms -from django.db.models import Q from django.utils.encoding import force_text @@ -66,51 +65,3 @@ def clean(self, value): 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): - self.null_value = kwargs.get('null_value', 0) - 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 by "is null" - if v == force_text(self.null_value): - arg = {'{}__isnull'.format(self.name): True} - # 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: - 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/virtualization/filters.py b/netbox/virtualization/filters.py index 4ddad4d5ba3..123cd30af37 100644 --- a/netbox/virtualization/filters.py +++ b/netbox/virtualization/filters.py @@ -9,7 +9,7 @@ from dcim.models import DeviceRole, Interface, Platform, Site from extras.filters import CustomFieldFilterSet from tenancy.models import Tenant -from utilities.filters import NullableModelMultipleChoiceFilter, NumericInFilter +from utilities.filters import NumericInFilter from .constants import STATUS_CHOICES from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine @@ -20,11 +20,11 @@ class ClusterFilter(CustomFieldFilterSet): method='search', label='Search', ) - group_id = NullableModelMultipleChoiceFilter( + group_id = django_filters.ModelMultipleChoiceFilter( queryset=ClusterGroup.objects.all(), label='Parent group (ID)', ) - group = NullableModelMultipleChoiceFilter( + group = django_filters.ModelMultipleChoiceFilter( queryset=ClusterGroup.objects.all(), to_field_name='slug', label='Parent group (slug)', @@ -72,12 +72,12 @@ class VirtualMachineFilter(CustomFieldFilterSet): status = django_filters.MultipleChoiceFilter( choices=STATUS_CHOICES ) - cluster_group_id = NullableModelMultipleChoiceFilter( + cluster_group_id = django_filters.ModelMultipleChoiceFilter( name='cluster__group', queryset=ClusterGroup.objects.all(), label='Cluster group (ID)', ) - cluster_group = NullableModelMultipleChoiceFilter( + cluster_group = django_filters.ModelMultipleChoiceFilter( name='cluster__group', queryset=ClusterGroup.objects.all(), to_field_name='slug', @@ -87,29 +87,29 @@ class VirtualMachineFilter(CustomFieldFilterSet): queryset=Cluster.objects.all(), label='Cluster (ID)', ) - role_id = NullableModelMultipleChoiceFilter( + role_id = django_filters.ModelMultipleChoiceFilter( queryset=DeviceRole.objects.all(), label='Role (ID)', ) - role = NullableModelMultipleChoiceFilter( + role = django_filters.ModelMultipleChoiceFilter( queryset=DeviceRole.objects.all(), to_field_name='slug', label='Role (slug)', ) - tenant_id = NullableModelMultipleChoiceFilter( + tenant_id = django_filters.ModelMultipleChoiceFilter( queryset=Tenant.objects.all(), label='Tenant (ID)', ) - tenant = NullableModelMultipleChoiceFilter( + tenant = django_filters.ModelMultipleChoiceFilter( queryset=Tenant.objects.all(), to_field_name='slug', label='Tenant (slug)', ) - platform_id = NullableModelMultipleChoiceFilter( + platform_id = django_filters.ModelMultipleChoiceFilter( queryset=Platform.objects.all(), label='Platform (ID)', ) - platform = NullableModelMultipleChoiceFilter( + platform = django_filters.ModelMultipleChoiceFilter( queryset=Platform.objects.all(), to_field_name='slug', label='Platform (slug)', diff --git a/requirements.txt b/requirements.txt index cdda3cf1d04..303d2ad47f7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ Django>=1.11,<2.0 django-cors-headers>=2.1 django-debug-toolbar>=1.8 -django-filter>=1.0.4 +django-filter>=1.1.0 django-mptt==0.8.7 django-rest-swagger>=2.1.0 django-tables2>=1.10.0 From bbd0761887a6b01736195e79530a40ddf42d3790 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 30 Oct 2017 17:45:05 -0400 Subject: [PATCH 20/23] Fixes #1653: Remove outdated description for DeviceType's is_network_device flag --- netbox/templates/dcim/devicetype.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/templates/dcim/devicetype.html b/netbox/templates/dcim/devicetype.html index 9eb3e0a5e31..12281734b8f 100644 --- a/netbox/templates/dcim/devicetype.html +++ b/netbox/templates/dcim/devicetype.html @@ -120,7 +120,7 @@

{% block title %}{{ devicetype.manufacturer }} {{ devicetype.model }}{% endb Network Device
- This device {% if devicetype.is_network_device %}has{% else %}does not have{% endif %} non-management network interfaces + This device {% if devicetype.is_network_device %}has{% else %}does not have{% endif %} network interfaces From d08bc7767e02741b60dbe705b089a4229a865f6e Mon Sep 17 00:00:00 2001 From: wanglf Date: Tue, 31 Oct 2017 22:05:30 +0800 Subject: [PATCH 21/23] Fixed missing field 'serial' in function to_csv() --- netbox/dcim/models.py | 1 + 1 file changed, 1 insertion(+) diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index e86c702f45b..5d2acb09ba6 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -296,6 +296,7 @@ def to_csv(self): self.tenant.name if self.tenant else None, self.role.name if self.role else None, self.get_type_display() if self.type else None, + self.serial, self.width, self.u_height, self.desc_units, From b46cc2c1a9969540240a525dae9e2c5db7cb6aae Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 31 Oct 2017 13:52:35 -0400 Subject: [PATCH 22/23] Closes #1666: Allow modifying the owner of a rack reservation --- netbox/dcim/api/serializers.py | 2 +- netbox/dcim/forms.py | 13 ++++++++++- .../0049_rackreservation_change_user.py | 22 +++++++++++++++++++ netbox/dcim/models.py | 2 +- netbox/dcim/urls.py | 1 + netbox/dcim/views.py | 10 +++++++++ .../templates/dcim/rackreservation_list.html | 2 +- 7 files changed, 48 insertions(+), 4 deletions(-) create mode 100644 netbox/dcim/migrations/0049_rackreservation_change_user.py diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index fa0f6dc140d..f92c1f64e0e 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -221,7 +221,7 @@ class WritableRackReservationSerializer(ValidatedModelSerializer): class Meta: model = RackReservation - fields = ['id', 'rack', 'units', 'description'] + fields = ['id', 'rack', 'units', 'user', 'description'] # diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index c3a545f7cc3..c4ec531f326 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -4,6 +4,7 @@ import re from django import forms +from django.contrib.auth.models import User from django.contrib.postgres.forms.array import SimpleArrayField from django.db.models import Count, Q @@ -376,10 +377,11 @@ class RackFilterForm(BootstrapMixin, CustomFieldFilterForm): class RackReservationForm(BootstrapMixin, forms.ModelForm): units = SimpleArrayField(forms.IntegerField(), widget=ArrayFieldSelectMultiple(attrs={'size': 10})) + user = forms.ModelChoiceField(queryset=User.objects.order_by('username')) class Meta: model = RackReservation - fields = ['units', 'description'] + fields = ['units', 'user', 'description'] def __init__(self, *args, **kwargs): @@ -411,6 +413,15 @@ class RackReservationFilterForm(BootstrapMixin, forms.Form): ) +class RackReservationBulkEditForm(BootstrapMixin, BulkEditForm): + pk = forms.ModelMultipleChoiceField(queryset=RackReservation.objects.all(), widget=forms.MultipleHiddenInput) + user = forms.ModelChoiceField(queryset=User.objects.order_by('username'), required=False) + description = forms.CharField(max_length=100, required=False) + + class Meta: + nullable_fields = [] + + # # Manufacturers # diff --git a/netbox/dcim/migrations/0049_rackreservation_change_user.py b/netbox/dcim/migrations/0049_rackreservation_change_user.py new file mode 100644 index 00000000000..ae9f95246ec --- /dev/null +++ b/netbox/dcim/migrations/0049_rackreservation_change_user.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.6 on 2017-10-31 17:32 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0048_rack_serial'), + ] + + operations = [ + migrations.AlterField( + model_name='rackreservation', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 5d2acb09ba6..daa4c80cdb0 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -418,7 +418,7 @@ class RackReservation(models.Model): rack = models.ForeignKey('Rack', related_name='reservations', on_delete=models.CASCADE) units = ArrayField(models.PositiveSmallIntegerField()) created = models.DateTimeField(auto_now_add=True) - user = models.ForeignKey(User, editable=False, on_delete=models.PROTECT) + user = models.ForeignKey(User, on_delete=models.PROTECT) description = models.CharField(max_length=100) class Meta: diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index 633cf9a8bbd..2d00f096da7 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -45,6 +45,7 @@ # Rack reservations url(r'^rack-reservations/$', views.RackReservationListView.as_view(), name='rackreservation_list'), + url(r'^rack-reservations/edit/$', views.RackReservationBulkEditView.as_view(), name='rackreservation_bulk_edit'), url(r'^rack-reservations/delete/$', views.RackReservationBulkDeleteView.as_view(), name='rackreservation_bulk_delete'), url(r'^rack-reservations/(?P\d+)/edit/$', views.RackReservationEditView.as_view(), name='rackreservation_edit'), url(r'^rack-reservations/(?P\d+)/delete/$', views.RackReservationDeleteView.as_view(), name='rackreservation_delete'), diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index e0ab7f1f2bb..3120197d20b 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -426,6 +426,16 @@ def get_return_url(self, request, obj): return obj.rack.get_absolute_url() +class RackReservationBulkEditView(PermissionRequiredMixin, BulkEditView): + permission_required = 'dcim.change_rackreservation' + cls = RackReservation + queryset = RackReservation.objects.select_related('rack', 'user') + filter = filters.RackReservationFilter + table = tables.RackReservationTable + form = forms.RackReservationBulkEditForm + default_return_url = 'dcim:rackreservation_list' + + class RackReservationBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_rackreservation' cls = RackReservation diff --git a/netbox/templates/dcim/rackreservation_list.html b/netbox/templates/dcim/rackreservation_list.html index b93dc0bf1f5..f1524d32834 100644 --- a/netbox/templates/dcim/rackreservation_list.html +++ b/netbox/templates/dcim/rackreservation_list.html @@ -5,7 +5,7 @@

{% block title %}Rack Reservations{% endblock %}

- {% include 'utilities/obj_table.html' with bulk_delete_url='dcim:rackreservation_bulk_delete' %} + {% include 'utilities/obj_table.html' with bulk_edit_url='dcim:rackreservation_bulk_edit' bulk_delete_url='dcim:rackreservation_bulk_delete' %}
{% include 'inc/search_panel.html' %} From cfa6bee08116211d0d8eebb124911c589d571343 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 31 Oct 2017 13:58:27 -0400 Subject: [PATCH 23/23] Release v2.2.3 --- 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 572d5a40a5c..af5e6dbaa07 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -13,7 +13,7 @@ ) -VERSION = '2.2.3-dev' +VERSION = '2.2.3' BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))