From 10917123fdd5cc00ccad73fc7ecf3921fd4e6ddc Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 21 Jan 2020 16:23:39 -0500 Subject: [PATCH 01/28] Add tests for cable tracing endpoints --- netbox/dcim/tests/test_api.py | 126 ++++++++++++++++++++++++++++++++++ 1 file changed, 126 insertions(+) diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index a515df13c..724d6b23b 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -4,6 +4,7 @@ from netaddr import IPNetwork from rest_framework import status from circuits.models import Circuit, CircuitTermination, CircuitType, Provider +from dcim.api import serializers from dcim.choices import * from dcim.constants import * from dcim.models import ( @@ -2134,6 +2135,31 @@ class ConsolePortTest(APITestCase): self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) self.assertEqual(ConsolePort.objects.count(), 2) + def test_trace_consoleport(self): + + peer_device = Device.objects.create( + site=Site.objects.first(), + device_type=DeviceType.objects.first(), + device_role=DeviceRole.objects.first(), + name='Peer Device' + ) + console_server_port = ConsoleServerPort.objects.create( + device=peer_device, + name='Console Server Port 1' + ) + cable = Cable(termination_a=self.consoleport1, termination_b=console_server_port, label='Cable 1') + cable.save() + + url = reverse('dcim-api:consoleport-trace', kwargs={'pk': self.consoleport1.pk}) + response = self.client.get(url, **self.header) + + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(len(response.data), 1) + segment1 = response.data[0] + self.assertEqual(segment1[0]['name'], self.consoleport1.name) + self.assertEqual(segment1[1]['label'], cable.label) + self.assertEqual(segment1[2]['name'], console_server_port.name) + class ConsoleServerPortTest(APITestCase): @@ -2245,6 +2271,31 @@ class ConsoleServerPortTest(APITestCase): self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) self.assertEqual(ConsoleServerPort.objects.count(), 2) + def test_trace_consoleserverport(self): + + peer_device = Device.objects.create( + site=Site.objects.first(), + device_type=DeviceType.objects.first(), + device_role=DeviceRole.objects.first(), + name='Peer Device' + ) + console_port = ConsolePort.objects.create( + device=peer_device, + name='Console Port 1' + ) + cable = Cable(termination_a=self.consoleserverport1, termination_b=console_port, label='Cable 1') + cable.save() + + url = reverse('dcim-api:consoleserverport-trace', kwargs={'pk': self.consoleserverport1.pk}) + response = self.client.get(url, **self.header) + + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(len(response.data), 1) + segment1 = response.data[0] + self.assertEqual(segment1[0]['name'], self.consoleserverport1.name) + self.assertEqual(segment1[1]['label'], cable.label) + self.assertEqual(segment1[2]['name'], console_port.name) + class PowerPortTest(APITestCase): @@ -2358,6 +2409,31 @@ class PowerPortTest(APITestCase): self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) self.assertEqual(PowerPort.objects.count(), 2) + def test_trace_powerport(self): + + peer_device = Device.objects.create( + site=Site.objects.first(), + device_type=DeviceType.objects.first(), + device_role=DeviceRole.objects.first(), + name='Peer Device' + ) + power_outlet = PowerOutlet.objects.create( + device=peer_device, + name='Power Outlet 1' + ) + cable = Cable(termination_a=self.powerport1, termination_b=power_outlet, label='Cable 1') + cable.save() + + url = reverse('dcim-api:powerport-trace', kwargs={'pk': self.powerport1.pk}) + response = self.client.get(url, **self.header) + + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(len(response.data), 1) + segment1 = response.data[0] + self.assertEqual(segment1[0]['name'], self.powerport1.name) + self.assertEqual(segment1[1]['label'], cable.label) + self.assertEqual(segment1[2]['name'], power_outlet.name) + class PowerOutletTest(APITestCase): @@ -2469,6 +2545,31 @@ class PowerOutletTest(APITestCase): self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) self.assertEqual(PowerOutlet.objects.count(), 2) + def test_trace_poweroutlet(self): + + peer_device = Device.objects.create( + site=Site.objects.first(), + device_type=DeviceType.objects.first(), + device_role=DeviceRole.objects.first(), + name='Peer Device' + ) + power_port = PowerPort.objects.create( + device=peer_device, + name='Power Port 1' + ) + cable = Cable(termination_a=self.poweroutlet1, termination_b=power_port, label='Cable 1') + cable.save() + + url = reverse('dcim-api:poweroutlet-trace', kwargs={'pk': self.poweroutlet1.pk}) + response = self.client.get(url, **self.header) + + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(len(response.data), 1) + segment1 = response.data[0] + self.assertEqual(segment1[0]['name'], self.poweroutlet1.name) + self.assertEqual(segment1[1]['label'], cable.label) + self.assertEqual(segment1[2]['name'], power_port.name) + class InterfaceTest(APITestCase): @@ -2672,6 +2773,31 @@ class InterfaceTest(APITestCase): self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) self.assertEqual(Interface.objects.count(), 2) + def test_trace_interface(self): + + peer_device = Device.objects.create( + site=Site.objects.first(), + device_type=DeviceType.objects.first(), + device_role=DeviceRole.objects.first(), + name='Peer Device' + ) + peer_interface = Interface.objects.create( + device=peer_device, + name='Power Outlet 1' + ) + cable = Cable(termination_a=self.interface1, termination_b=peer_interface, label='Cable 1') + cable.save() + + url = reverse('dcim-api:interface-trace', kwargs={'pk': self.interface1.pk}) + response = self.client.get(url, **self.header) + + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(len(response.data), 1) + segment1 = response.data[0] + self.assertEqual(segment1[0]['name'], self.interface1.name) + self.assertEqual(segment1[1]['label'], cable.label) + self.assertEqual(segment1[2]['name'], peer_interface.name) + class DeviceBayTest(APITestCase): From d123664503cfc7e0e6c9e7800253cff6e947e21a Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 21 Jan 2020 17:00:30 -0500 Subject: [PATCH 02/28] Add tests for front/rear port API endpoints --- netbox/dcim/tests/test_api.py | 269 +++++++++++++++++++++++++++++++--- 1 file changed, 250 insertions(+), 19 deletions(-) diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 724d6b23b..36f3b219f 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -2773,30 +2773,261 @@ class InterfaceTest(APITestCase): self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) self.assertEqual(Interface.objects.count(), 2) - def test_trace_interface(self): - peer_device = Device.objects.create( - site=Site.objects.first(), - device_type=DeviceType.objects.first(), - device_role=DeviceRole.objects.first(), - name='Peer Device' - ) - peer_interface = Interface.objects.create( - device=peer_device, - name='Power Outlet 1' - ) - cable = Cable(termination_a=self.interface1, termination_b=peer_interface, label='Cable 1') - cable.save() +class FrontPortTest(APITestCase): - url = reverse('dcim-api:interface-trace', kwargs={'pk': self.interface1.pk}) + def setUp(self): + + super().setUp() + + site = Site.objects.create(name='Test Site 1', slug='test-site-1') + manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') + devicetype = DeviceType.objects.create( + manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1' + ) + devicerole = DeviceRole.objects.create( + name='Test Device Role 1', slug='test-device-role-1', color='ff0000' + ) + self.device = Device.objects.create( + device_type=devicetype, device_role=devicerole, name='Test Device 1', site=site + ) + rear_ports = RearPort.objects.bulk_create(( + RearPort(device=self.device, name='Rear Port 1', type=PortTypeChoices.TYPE_8P8C), + RearPort(device=self.device, name='Rear Port 2', type=PortTypeChoices.TYPE_8P8C), + RearPort(device=self.device, name='Rear Port 3', type=PortTypeChoices.TYPE_8P8C), + RearPort(device=self.device, name='Rear Port 4', type=PortTypeChoices.TYPE_8P8C), + RearPort(device=self.device, name='Rear Port 5', type=PortTypeChoices.TYPE_8P8C), + RearPort(device=self.device, name='Rear Port 6', type=PortTypeChoices.TYPE_8P8C), + )) + self.frontport1 = FrontPort.objects.create(device=self.device, name='Front Port 1', type=PortTypeChoices.TYPE_8P8C, rear_port=rear_ports[0]) + self.frontport3 = FrontPort.objects.create(device=self.device, name='Front Port 2', type=PortTypeChoices.TYPE_8P8C, rear_port=rear_ports[1]) + self.frontport1 = FrontPort.objects.create(device=self.device, name='Front Port 3', type=PortTypeChoices.TYPE_8P8C, rear_port=rear_ports[2]) + + def test_get_frontport(self): + + url = reverse('dcim-api:frontport-detail', kwargs={'pk': self.frontport1.pk}) response = self.client.get(url, **self.header) + self.assertEqual(response.data['name'], self.frontport1.name) + + def test_list_frontports(self): + + url = reverse('dcim-api:frontport-list') + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['count'], 3) + + def test_list_frontports_brief(self): + + url = reverse('dcim-api:frontport-list') + response = self.client.get('{}?brief=1'.format(url), **self.header) + + self.assertEqual( + sorted(response.data['results'][0]), + ['cable', 'device', 'id', 'name', 'url'] + ) + + def test_create_frontport(self): + + rear_port = RearPort.objects.get(name='Rear Port 4') + data = { + 'device': self.device.pk, + 'name': 'Front Port 4', + 'type': PortTypeChoices.TYPE_8P8C, + 'rear_port': rear_port.pk, + 'rear_port_position': 1, + } + + url = reverse('dcim-api:frontport-list') + response = self.client.post(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(FrontPort.objects.count(), 4) + frontport4 = FrontPort.objects.get(pk=response.data['id']) + self.assertEqual(frontport4.device_id, data['device']) + self.assertEqual(frontport4.name, data['name']) + + def test_create_frontport_bulk(self): + + rear_ports = RearPort.objects.filter(frontports__isnull=True) + data = [ + { + 'device': self.device.pk, + 'name': 'Front Port 4', + 'type': PortTypeChoices.TYPE_8P8C, + 'rear_port': rear_ports[0].pk, + 'rear_port_position': 1, + }, + { + 'device': self.device.pk, + 'name': 'Front Port 5', + 'type': PortTypeChoices.TYPE_8P8C, + 'rear_port': rear_ports[1].pk, + 'rear_port_position': 1, + }, + { + 'device': self.device.pk, + 'name': 'Front Port 6', + 'type': PortTypeChoices.TYPE_8P8C, + 'rear_port': rear_ports[2].pk, + 'rear_port_position': 1, + }, + ] + + url = reverse('dcim-api:frontport-list') + response = self.client.post(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(FrontPort.objects.count(), 6) + self.assertEqual(response.data[0]['name'], data[0]['name']) + self.assertEqual(response.data[1]['name'], data[1]['name']) + self.assertEqual(response.data[2]['name'], data[2]['name']) + + def test_update_frontport(self): + + rear_port = RearPort.objects.get(name='Rear Port 4') + data = { + 'device': self.device.pk, + 'name': 'Front Port X', + 'type': PortTypeChoices.TYPE_110_PUNCH, + 'rear_port': rear_port.pk, + 'rear_port_position': 1, + } + + url = reverse('dcim-api:frontport-detail', kwargs={'pk': self.frontport1.pk}) + response = self.client.put(url, data, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_200_OK) - self.assertEqual(len(response.data), 1) - segment1 = response.data[0] - self.assertEqual(segment1[0]['name'], self.interface1.name) - self.assertEqual(segment1[1]['label'], cable.label) - self.assertEqual(segment1[2]['name'], peer_interface.name) + self.assertEqual(FrontPort.objects.count(), 3) + frontport1 = FrontPort.objects.get(pk=response.data['id']) + self.assertEqual(frontport1.name, data['name']) + self.assertEqual(frontport1.type, data['type']) + self.assertEqual(frontport1.rear_port, rear_port) + + def test_delete_frontport(self): + + url = reverse('dcim-api:frontport-detail', kwargs={'pk': self.frontport1.pk}) + response = self.client.delete(url, **self.header) + + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) + self.assertEqual(FrontPort.objects.count(), 2) + + +class RearPortTest(APITestCase): + + def setUp(self): + + super().setUp() + + site = Site.objects.create(name='Test Site 1', slug='test-site-1') + manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') + devicetype = DeviceType.objects.create( + manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1' + ) + devicerole = DeviceRole.objects.create( + name='Test Device Role 1', slug='test-device-role-1', color='ff0000' + ) + self.device = Device.objects.create( + device_type=devicetype, device_role=devicerole, name='Test Device 1', site=site + ) + self.rearport1 = RearPort.objects.create(device=self.device, type=PortTypeChoices.TYPE_8P8C, name='Rear Port 1') + self.rearport3 = RearPort.objects.create(device=self.device, type=PortTypeChoices.TYPE_8P8C, name='Rear Port 2') + self.rearport1 = RearPort.objects.create(device=self.device, type=PortTypeChoices.TYPE_8P8C, name='Rear Port 3') + + def test_get_rearport(self): + + url = reverse('dcim-api:rearport-detail', kwargs={'pk': self.rearport1.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['name'], self.rearport1.name) + + def test_list_rearports(self): + + url = reverse('dcim-api:rearport-list') + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['count'], 3) + + def test_list_rearports_brief(self): + + url = reverse('dcim-api:rearport-list') + response = self.client.get('{}?brief=1'.format(url), **self.header) + + self.assertEqual( + sorted(response.data['results'][0]), + ['cable', 'device', 'id', 'name', 'url'] + ) + + def test_create_rearport(self): + + data = { + 'device': self.device.pk, + 'name': 'Front Port 4', + 'type': PortTypeChoices.TYPE_8P8C, + } + + url = reverse('dcim-api:rearport-list') + response = self.client.post(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(RearPort.objects.count(), 4) + rearport4 = RearPort.objects.get(pk=response.data['id']) + self.assertEqual(rearport4.device_id, data['device']) + self.assertEqual(rearport4.name, data['name']) + + def test_create_rearport_bulk(self): + + data = [ + { + 'device': self.device.pk, + 'name': 'Rear Port 4', + 'type': PortTypeChoices.TYPE_8P8C, + }, + { + 'device': self.device.pk, + 'name': 'Rear Port 5', + 'type': PortTypeChoices.TYPE_8P8C, + }, + { + 'device': self.device.pk, + 'name': 'Rear Port 6', + 'type': PortTypeChoices.TYPE_8P8C, + }, + ] + + url = reverse('dcim-api:rearport-list') + response = self.client.post(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(RearPort.objects.count(), 6) + self.assertEqual(response.data[0]['name'], data[0]['name']) + self.assertEqual(response.data[1]['name'], data[1]['name']) + self.assertEqual(response.data[2]['name'], data[2]['name']) + + def test_update_rearport(self): + + data = { + 'device': self.device.pk, + 'name': 'Front Port X', + 'type': PortTypeChoices.TYPE_110_PUNCH + } + + url = reverse('dcim-api:rearport-detail', kwargs={'pk': self.rearport1.pk}) + response = self.client.put(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(RearPort.objects.count(), 3) + rearport1 = RearPort.objects.get(pk=response.data['id']) + self.assertEqual(rearport1.name, data['name']) + self.assertEqual(rearport1.type, data['type']) + + def test_delete_rearport(self): + + url = reverse('dcim-api:rearport-detail', kwargs={'pk': self.rearport1.pk}) + response = self.client.delete(url, **self.header) + + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) + self.assertEqual(RearPort.objects.count(), 2) class DeviceBayTest(APITestCase): From 03436b729df13f1367945c3e9aad3561012dc278 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 21 Jan 2020 17:11:26 -0500 Subject: [PATCH 03/28] Add test for device graphs API endpoint --- netbox/dcim/tests/test_api.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 36f3b219f..ec0f72c6d 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -1901,6 +1901,31 @@ class DeviceTest(APITestCase): self.assertEqual(response.data['device_role']['id'], self.devicerole1.pk) self.assertEqual(response.data['cluster']['id'], self.cluster1.pk) + def test_get_device_graphs(self): + + device_ct = ContentType.objects.get_for_model(Device) + self.graph1 = Graph.objects.create( + type=device_ct, + name='Test Graph 1', + source='http://example.com/graphs.py?device={{ obj.name }}&foo=1' + ) + self.graph2 = Graph.objects.create( + type=device_ct, + name='Test Graph 2', + source='http://example.com/graphs.py?device={{ obj.name }}&foo=2' + ) + self.graph3 = Graph.objects.create( + type=device_ct, + name='Test Graph 3', + source='http://example.com/graphs.py?device={{ obj.name }}&foo=3' + ) + + url = reverse('dcim-api:device-graphs', kwargs={'pk': self.device1.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(len(response.data), 3) + self.assertEqual(response.data[0]['embed_url'], 'http://example.com/graphs.py?device=Test Device 1&foo=1') + def test_list_devices(self): url = reverse('dcim-api:device-list') From 1c13a7996163b1d89e94a4b79c2dcb6602bd1879 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 21 Jan 2020 17:18:21 -0500 Subject: [PATCH 04/28] Suppress extraneous test output --- netbox/secrets/tests/test_form.py | 1 - 1 file changed, 1 deletion(-) diff --git a/netbox/secrets/tests/test_form.py b/netbox/secrets/tests/test_form.py index 42111abbf..d122358cc 100644 --- a/netbox/secrets/tests/test_form.py +++ b/netbox/secrets/tests/test_form.py @@ -29,5 +29,4 @@ class UserKeyFormTestCase(TestCase): data={'public_key': SSH_PUBLIC_KEY}, instance=self.userkey, ) - print(form.is_valid()) self.assertFalse(form.is_valid()) From ddd9f860317394f26ef40a3de82f02e3e94ab7d2 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 21 Jan 2020 17:36:38 -0500 Subject: [PATCH 05/28] Add tests for rack elevation API endpoint --- netbox/dcim/tests/test_api.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index ec0f72c6d..a3a072bc9 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -596,6 +596,21 @@ class RackTest(APITestCase): self.assertEqual(response.data['count'], 42) + def test_get_rack_elevation(self): + + url = reverse('dcim-api:rack-elevation', kwargs={'pk': self.rack1.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['count'], 42) + + def test_get_rack_elevation_svg(self): + + url = '{}?render=svg'.format(reverse('dcim-api:rack-elevation', kwargs={'pk': self.rack1.pk})) + response = self.client.get(url, **self.header) + + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(response.get('Content-Type'), 'image/svg+xml') + def test_list_racks(self): url = reverse('dcim-api:rack-list') From b7dea5a9f764c5f511ecc8b9c8805ba1b4e49783 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 22 Jan 2020 09:26:49 -0500 Subject: [PATCH 06/28] Fixes #3983: Permit the creation of multiple unnamed devices --- docs/release-notes/version-2.7.md | 8 ++++++++ netbox/dcim/models/__init__.py | 9 +++++---- netbox/dcim/tests/test_models.py | 23 ++++++++++++++++++++++- 3 files changed, 35 insertions(+), 5 deletions(-) diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index fb544d8a8..09466d525 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -1,3 +1,11 @@ +# v2.7.3 (FUTURE) + +## Bug Fixes + +* [#3983](https://github.com/netbox-community/netbox/issues/3983) - Permit the creation of multiple unnamed devices + +--- + # v2.7.2 (2020-01-21) ## Enhancements diff --git a/netbox/dcim/models/__init__.py b/netbox/dcim/models/__init__.py index d1b596c22..1c9c8682d 100644 --- a/netbox/dcim/models/__init__.py +++ b/netbox/dcim/models/__init__.py @@ -1445,10 +1445,11 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): # Check for a duplicate name on a device assigned to the same Site and no Tenant. This is necessary # because Django does not consider two NULL fields to be equal, and thus will not trigger a violation # of the uniqueness constraint without manual intervention. - if self.tenant is None and Device.objects.exclude(pk=self.pk).filter(name=self.name, tenant__isnull=True): - raise ValidationError({ - 'name': 'A device with this name already exists.' - }) + if self.name and self.tenant is None: + if Device.objects.exclude(pk=self.pk).filter(name=self.name, tenant__isnull=True): + raise ValidationError({ + 'name': 'A device with this name already exists.' + }) super().validate_unique(exclude) diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index 7573d2cc4..32d864a51 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -285,7 +285,28 @@ class DeviceTestCase(TestCase): name='Device Bay 1' ) - def test_device_duplicate_name_per_site(self): + def test_multiple_unnamed_devices(self): + + device1 = Device( + site=self.site, + device_type=self.device_type, + device_role=self.device_role, + name='' + ) + device1.save() + + device2 = Device( + site=device1.site, + device_type=device1.device_type, + device_role=device1.device_role, + name='' + ) + device2.full_clean() + device2.save() + + self.assertEqual(Device.objects.filter(name='').count(), 2) + + def test_device_duplicate_names(self): device1 = Device( site=self.site, From f826e156031e7c057716b5dd3590a3ab52fa1ec1 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 22 Jan 2020 16:07:09 -0500 Subject: [PATCH 07/28] Closes #3310: Pre-select site/rack for B side when creating a new cable --- docs/release-notes/version-2.7.md | 4 ++++ netbox/dcim/forms.py | 10 ++++++++++ netbox/dcim/views.py | 6 ++++++ netbox/templates/dcim/cable_connect.html | 21 ++------------------- netbox/templates/dcim/cable_edit.html | 20 +------------------- netbox/templates/dcim/inc/cable_form.html | 19 +++++++++++++++++++ 6 files changed, 42 insertions(+), 38 deletions(-) create mode 100644 netbox/templates/dcim/inc/cable_form.html diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index 09466d525..971d0a60c 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -1,5 +1,9 @@ # v2.7.3 (FUTURE) +## Enhancements + +* [#3310](https://github.com/netbox-community/netbox/issues/3310) - Pre-select site/rack for B side when creating a new cable + ## Bug Fixes * [#3983](https://github.com/netbox-community/netbox/issues/3983) - Permit the creation of multiple unnamed devices diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index cc18981c4..84d83a94c 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -3168,6 +3168,11 @@ class ConnectCableToDeviceForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelFo 'termination_b_site', 'termination_b_rack', 'termination_b_device', 'termination_b_id', 'type', 'status', 'label', 'color', 'length', 'length_unit', ] + widgets = { + 'status': StaticSelect2, + 'type': StaticSelect2, + 'length_unit': StaticSelect2, + } class ConnectCableToConsolePortForm(ConnectCableToDeviceForm): @@ -3363,6 +3368,11 @@ class CableForm(BootstrapMixin, forms.ModelForm): fields = [ 'type', 'status', 'label', 'color', 'length', 'length_unit', ] + widgets = { + 'status': StaticSelect2, + 'type': StaticSelect2, + 'length_unit': StaticSelect2, + } class CableCSVForm(forms.ModelForm): diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index e41d44d95..c83cee8fb 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1945,6 +1945,12 @@ class CableCreateView(PermissionRequiredMixin, GetReturnURLMixin, View): # Parse initial data manually to avoid setting field values as lists initial_data = {k: request.GET[k] for k in request.GET} + # Set initial site and rack based on side A termination (if not already set) + if 'termination_b_site' not in initial_data: + initial_data['termination_b_site'] = getattr(self.obj.termination_a.parent, 'site', None) + if 'termination_b_rack' not in initial_data: + initial_data['termination_b_rack'] = getattr(self.obj.termination_a.parent, 'rack', None) + form = self.form_class(instance=self.obj, initial=initial_data) return render(request, self.template_name, { diff --git a/netbox/templates/dcim/cable_connect.html b/netbox/templates/dcim/cable_connect.html index b1609f578..aa4c4bf8c 100644 --- a/netbox/templates/dcim/cable_connect.html +++ b/netbox/templates/dcim/cable_connect.html @@ -144,25 +144,8 @@
-
-
-
Cable
-
- {% render_field form.status %} - {% render_field form.type %} - {% render_field form.label %} - {% render_field form.color %} -
- -
- {{ form.length }} -
-
- {{ form.length_unit }} -
-
-
-
+
+ {% include 'dcim/inc/cable_form.html' %}
diff --git a/netbox/templates/dcim/cable_edit.html b/netbox/templates/dcim/cable_edit.html index 17403a07d..685b68206 100644 --- a/netbox/templates/dcim/cable_edit.html +++ b/netbox/templates/dcim/cable_edit.html @@ -1,23 +1,5 @@ {% extends 'utilities/obj_edit.html' %} -{% load form_helpers %} {% block form %} -
-
Cable
-
- {% render_field form.type %} - {% render_field form.status %} - {% render_field form.label %} - {% render_field form.color %} -
- -
- {{ form.length }} -
-
- {{ form.length_unit }} -
-
-
-
+ {% include 'dcim/inc/cable_form.html' %} {% endblock %} diff --git a/netbox/templates/dcim/inc/cable_form.html b/netbox/templates/dcim/inc/cable_form.html new file mode 100644 index 000000000..0799eb130 --- /dev/null +++ b/netbox/templates/dcim/inc/cable_form.html @@ -0,0 +1,19 @@ +{% load form_helpers %} +
+
Cable
+
+ {% render_field form.status %} + {% render_field form.type %} + {% render_field form.label %} + {% render_field form.color %} +
+ +
+ {{ form.length }} +
+
+ {{ form.length_unit }} +
+
+
+
From ba6df87d10de7b92e8507566fce5d2eadcb224c2 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 22 Jan 2020 16:26:06 -0500 Subject: [PATCH 08/28] Move min/max prefix length validators to ipam.validators --- netbox/extras/scripts.py | 2 +- netbox/ipam/validators.py | 18 +++++++++++++++++- netbox/utilities/validators.py | 18 +----------------- 3 files changed, 19 insertions(+), 19 deletions(-) diff --git a/netbox/extras/scripts.py b/netbox/extras/scripts.py index fed003bed..b213a9a11 100644 --- a/netbox/extras/scripts.py +++ b/netbox/extras/scripts.py @@ -16,7 +16,7 @@ from mptt.models import MPTTModel from ipam.formfields import IPFormField from utilities.exceptions import AbortTransaction -from utilities.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator +from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator from .constants import LOG_DEFAULT, LOG_FAILURE, LOG_INFO, LOG_SUCCESS, LOG_WARNING from .forms import ScriptForm from .signals import purge_changelog diff --git a/netbox/ipam/validators.py b/netbox/ipam/validators.py index 960675643..f95e174a3 100644 --- a/netbox/ipam/validators.py +++ b/netbox/ipam/validators.py @@ -1,4 +1,20 @@ -from django.core.validators import RegexValidator +from django.core.validators import BaseValidator, RegexValidator + + +class MaxPrefixLengthValidator(BaseValidator): + message = 'The prefix length must be less than or equal to %(limit_value)s.' + code = 'max_prefix_length' + + def compare(self, a, b): + return a.prefixlen > b + + +class MinPrefixLengthValidator(BaseValidator): + message = 'The prefix length must be greater than or equal to %(limit_value)s.' + code = 'min_prefix_length' + + def compare(self, a, b): + return a.prefixlen < b DNSValidator = RegexValidator( diff --git a/netbox/utilities/validators.py b/netbox/utilities/validators.py index fb7a5bba7..cfa733208 100644 --- a/netbox/utilities/validators.py +++ b/netbox/utilities/validators.py @@ -1,6 +1,6 @@ import re -from django.core.validators import _lazy_re_compile, BaseValidator, URLValidator +from django.core.validators import _lazy_re_compile, URLValidator class EnhancedURLValidator(URLValidator): @@ -26,19 +26,3 @@ class EnhancedURLValidator(URLValidator): r'(?:[/?#][^\s]*)?' # Path r'\Z', re.IGNORECASE) schemes = AnyURLScheme() - - -class MaxPrefixLengthValidator(BaseValidator): - message = 'The prefix length must be less than or equal to %(limit_value)s.' - code = 'max_prefix_length' - - def compare(self, a, b): - return a.prefixlen > b - - -class MinPrefixLengthValidator(BaseValidator): - message = 'The prefix length must be greater than or equal to %(limit_value)s.' - code = 'min_prefix_length' - - def compare(self, a, b): - return a.prefixlen < b From aa56c020ab4939be6e3c56bcd904036fca1f2e56 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 22 Jan 2020 16:33:34 -0500 Subject: [PATCH 09/28] Move prefix_validator() to ipam.validators --- netbox/ipam/fields.py | 9 ++------- netbox/ipam/validators.py | 6 ++++++ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/netbox/ipam/fields.py b/netbox/ipam/fields.py index 72600d1b9..c08cce829 100644 --- a/netbox/ipam/fields.py +++ b/netbox/ipam/fields.py @@ -2,15 +2,10 @@ from django.core.exceptions import ValidationError from django.db import models from netaddr import AddrFormatError, IPNetwork -from . import lookups +from . import lookups, validators from .formfields import IPFormField -def prefix_validator(prefix): - if prefix.ip != prefix.cidr.ip: - raise ValidationError("{} is not a valid prefix. Did you mean {}?".format(prefix, prefix.cidr)) - - class BaseIPField(models.Field): def python_type(self): @@ -51,7 +46,7 @@ class IPNetworkField(BaseIPField): IP prefix (network and mask) """ description = "PostgreSQL CIDR field" - default_validators = [prefix_validator] + default_validators = [validators.prefix_validator] def db_type(self, connection): return 'cidr' diff --git a/netbox/ipam/validators.py b/netbox/ipam/validators.py index f95e174a3..879e20e6a 100644 --- a/netbox/ipam/validators.py +++ b/netbox/ipam/validators.py @@ -1,6 +1,12 @@ +from django.core.exceptions import ValidationError from django.core.validators import BaseValidator, RegexValidator +def prefix_validator(prefix): + if prefix.ip != prefix.cidr.ip: + raise ValidationError("{} is not a valid prefix. Did you mean {}?".format(prefix, prefix.cidr)) + + class MaxPrefixLengthValidator(BaseValidator): message = 'The prefix length must be less than or equal to %(limit_value)s.' code = 'max_prefix_length' From f41564b578dafb6d41f253acb847ae8b0c36e832 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 22 Jan 2020 17:14:31 -0500 Subject: [PATCH 10/28] Introduce IPAddressVar and IPAddressWithMaskVar --- docs/additional-features/custom-scripts.md | 15 +++++++- netbox/extras/scripts.py | 43 ++++++++++++++++------ netbox/ipam/fields.py | 4 +- netbox/ipam/formfields.py | 24 +++++++++++- 4 files changed, 69 insertions(+), 17 deletions(-) diff --git a/docs/additional-features/custom-scripts.md b/docs/additional-features/custom-scripts.md index c4dffb4b9..6fac5b63d 100644 --- a/docs/additional-features/custom-scripts.md +++ b/docs/additional-features/custom-scripts.md @@ -124,7 +124,7 @@ Arbitrary text of any length. Renders as multi-line text input field. Stored a numeric integer. Options include: -* `min_value:` - Minimum value +* `min_value` - Minimum value * `max_value` - Maximum value ### BooleanVar @@ -158,9 +158,20 @@ A NetBox object. The list of available objects is defined by the queryset parame An uploaded file. Note that uploaded files are present in memory only for the duration of the script's execution: They will not be save for future use. +### IPAddressVar + +An IPv4 or IPv6 address, without a mask. Returns a `netaddr.IPAddress` object. + +### IPAddressWithMaskVar + +An IPv4 or IPv6 address with a mask. Returns a `netaddr.IPNetwork` object which includes the mask. + ### IPNetworkVar -An IPv4 or IPv6 network with a mask. +An IPv4 or IPv6 network with a mask. Returns a `netaddr.IPNetwork` object. Two attributes are available to validate the provided mask: + +* `min_prefix_length` - Minimum length of the mask (default: none) +* `max_prefix_length` - Maximum length of the mask (default: none) ### Default Options diff --git a/netbox/extras/scripts.py b/netbox/extras/scripts.py index b213a9a11..bd7e864e1 100644 --- a/netbox/extras/scripts.py +++ b/netbox/extras/scripts.py @@ -14,10 +14,10 @@ from django.db import transaction from mptt.forms import TreeNodeChoiceField, TreeNodeMultipleChoiceField from mptt.models import MPTTModel -from ipam.formfields import IPFormField -from utilities.exceptions import AbortTransaction -from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator +from ipam.formfields import IPAddressFormField, IPNetworkFormField +from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator, prefix_validator from .constants import LOG_DEFAULT, LOG_FAILURE, LOG_INFO, LOG_SUCCESS, LOG_WARNING +from utilities.exceptions import AbortTransaction from .forms import ScriptForm from .signals import purge_changelog @@ -27,6 +27,8 @@ __all__ = [ 'ChoiceVar', 'FileVar', 'IntegerVar', + 'IPAddressVar', + 'IPAddressWithMaskVar', 'IPNetworkVar', 'MultiObjectVar', 'ObjectVar', @@ -48,15 +50,19 @@ class ScriptVariable: def __init__(self, label='', description='', default=None, required=True): - # Default field attributes - self.field_attrs = { - 'help_text': description, - 'required': required - } + # Initialize field attributes + if not hasattr(self, 'field_attrs'): + self.field_attrs = {} + if description: + self.field_attrs['help_text'] = description if label: self.field_attrs['label'] = label if default: self.field_attrs['initial'] = default + if required: + self.field_attrs['required'] = True + if 'validators' not in self.field_attrs: + self.field_attrs['validators'] = [] def as_field(self): """ @@ -196,17 +202,32 @@ class FileVar(ScriptVariable): form_field = forms.FileField +class IPAddressVar(ScriptVariable): + """ + An IPv4 or IPv6 address without a mask. + """ + form_field = IPAddressFormField + + +class IPAddressWithMaskVar(ScriptVariable): + """ + An IPv4 or IPv6 address with a mask. + """ + form_field = IPNetworkFormField + + class IPNetworkVar(ScriptVariable): """ An IPv4 or IPv6 prefix. """ - form_field = IPFormField + form_field = IPNetworkFormField + field_attrs = { + 'validators': [prefix_validator] + } def __init__(self, min_prefix_length=None, max_prefix_length=None, *args, **kwargs): super().__init__(*args, **kwargs) - self.field_attrs['validators'] = list() - # Optional minimum/maximum prefix lengths if min_prefix_length is not None: self.field_attrs['validators'].append( diff --git a/netbox/ipam/fields.py b/netbox/ipam/fields.py index c08cce829..456a7debc 100644 --- a/netbox/ipam/fields.py +++ b/netbox/ipam/fields.py @@ -3,7 +3,7 @@ from django.db import models from netaddr import AddrFormatError, IPNetwork from . import lookups, validators -from .formfields import IPFormField +from .formfields import IPNetworkFormField class BaseIPField(models.Field): @@ -33,7 +33,7 @@ class BaseIPField(models.Field): return str(self.to_python(value)) def form_class(self): - return IPFormField + return IPNetworkFormField def formfield(self, **kwargs): defaults = {'form_class': self.form_class()} diff --git a/netbox/ipam/formfields.py b/netbox/ipam/formfields.py index 2909a54b1..1ab0a8ce3 100644 --- a/netbox/ipam/formfields.py +++ b/netbox/ipam/formfields.py @@ -1,13 +1,33 @@ from django import forms from django.core.exceptions import ValidationError -from netaddr import IPNetwork, AddrFormatError +from netaddr import IPAddress, IPNetwork, AddrFormatError # # Form fields # -class IPFormField(forms.Field): +class IPAddressFormField(forms.Field): + default_error_messages = { + 'invalid': "Enter a valid IPv4 or IPv6 address (without a mask).", + } + + def to_python(self, value): + if not value: + return None + + if isinstance(value, IPAddress): + return value + + try: + return IPAddress(value) + except ValueError: + raise ValidationError('This field requires an IP address without a mask.') + except AddrFormatError: + raise ValidationError("Please specify a valid IPv4 or IPv6 address.") + + +class IPNetworkFormField(forms.Field): default_error_messages = { 'invalid': "Enter a valid IPv4 or IPv6 address (with CIDR mask).", } From b7e71f9f39ba3860b722ee70ee6ca58647943c78 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 22 Jan 2020 17:48:03 -0500 Subject: [PATCH 11/28] Add tests for IP address vars --- netbox/extras/tests/test_scripts.py | 56 ++++++++++++++++++++++++++++- netbox/ipam/formfields.py | 11 ++++++ 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/netbox/extras/tests/test_scripts.py b/netbox/extras/tests/test_scripts.py index 26e12772f..6237d1d95 100644 --- a/netbox/extras/tests/test_scripts.py +++ b/netbox/extras/tests/test_scripts.py @@ -1,6 +1,6 @@ from django.core.files.uploadedfile import SimpleUploadedFile from django.test import TestCase -from netaddr import IPNetwork +from netaddr import IPAddress, IPNetwork from dcim.models import DeviceRole from extras.scripts import * @@ -186,6 +186,54 @@ class ScriptVariablesTest(TestCase): self.assertTrue(form.is_valid()) self.assertEqual(form.cleaned_data['var1'], testfile) + def test_ipaddressvar(self): + + class TestScript(Script): + + var1 = IPAddressVar() + + # Validate IP network enforcement + data = {'var1': '1.2.3'} + form = TestScript().as_form(data, None) + self.assertFalse(form.is_valid()) + self.assertIn('var1', form.errors) + + # Validate IP mask exclusion + data = {'var1': '192.0.2.0/24'} + form = TestScript().as_form(data, None) + self.assertFalse(form.is_valid()) + self.assertIn('var1', form.errors) + + # Validate valid data + data = {'var1': '192.0.2.1'} + form = TestScript().as_form(data, None) + self.assertTrue(form.is_valid()) + self.assertEqual(form.cleaned_data['var1'], IPAddress(data['var1'])) + + def test_ipaddresswithmaskvar(self): + + class TestScript(Script): + + var1 = IPAddressWithMaskVar() + + # Validate IP network enforcement + data = {'var1': '1.2.3'} + form = TestScript().as_form(data, None) + self.assertFalse(form.is_valid()) + self.assertIn('var1', form.errors) + + # Validate IP mask requirement + data = {'var1': '192.0.2.0'} + form = TestScript().as_form(data, None) + self.assertFalse(form.is_valid()) + self.assertIn('var1', form.errors) + + # Validate valid data + data = {'var1': '192.0.2.0/24'} + form = TestScript().as_form(data, None) + self.assertTrue(form.is_valid()) + self.assertEqual(form.cleaned_data['var1'], IPNetwork(data['var1'])) + def test_ipnetworkvar(self): class TestScript(Script): @@ -198,6 +246,12 @@ class ScriptVariablesTest(TestCase): self.assertFalse(form.is_valid()) self.assertIn('var1', form.errors) + # Validate host IP check + data = {'var1': '192.0.2.1/24'} + form = TestScript().as_form(data, None) + self.assertFalse(form.is_valid()) + self.assertIn('var1', form.errors) + # Validate valid data data = {'var1': '192.0.2.0/24'} form = TestScript().as_form(data, None) diff --git a/netbox/ipam/formfields.py b/netbox/ipam/formfields.py index 1ab0a8ce3..e8d171d7f 100644 --- a/netbox/ipam/formfields.py +++ b/netbox/ipam/formfields.py @@ -1,5 +1,6 @@ from django import forms from django.core.exceptions import ValidationError +from django.core.validators import validate_ipv4_address, validate_ipv6_address from netaddr import IPAddress, IPNetwork, AddrFormatError @@ -19,6 +20,16 @@ class IPAddressFormField(forms.Field): if isinstance(value, IPAddress): return value + # netaddr is a bit too liberal with what it accepts as a valid IP address. For example, '1.2.3' will become + # IPAddress('1.2.0.3'). Here, we employ Django's built-in IPv4 and IPv6 address validators as a sanity check. + try: + validate_ipv4_address(value) + except ValidationError: + try: + validate_ipv6_address(value) + except ValidationError: + raise ValidationError("Invalid IPv4/IPv6 address format: {}".format(value)) + try: return IPAddress(value) except ValueError: From 72d1fe0cd773d062f4f573e17a4163fef3006e3f Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 22 Jan 2020 17:49:03 -0500 Subject: [PATCH 12/28] Changelog for #3509 --- docs/release-notes/version-2.7.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index 971d0a60c..befa9c58f 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -3,6 +3,7 @@ ## Enhancements * [#3310](https://github.com/netbox-community/netbox/issues/3310) - Pre-select site/rack for B side when creating a new cable +* [#3509](https://github.com/netbox-community/netbox/issues/3509) - Add IP address variables for custom scripts ## Bug Fixes From 7b517abdb68a6324950dfd0375861163c7bfff00 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 22 Jan 2020 20:33:57 -0500 Subject: [PATCH 13/28] Fixes #3989: Correct HTTP content type assignment for webhooks --- docs/release-notes/version-2.7.md | 1 + netbox/extras/webhooks_worker.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index befa9c58f..6df63fb69 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -8,6 +8,7 @@ ## Bug Fixes * [#3983](https://github.com/netbox-community/netbox/issues/3983) - Permit the creation of multiple unnamed devices +* [#3989](https://github.com/netbox-community/netbox/issues/3989) - Correct HTTP content type assignment for webhooks --- diff --git a/netbox/extras/webhooks_worker.py b/netbox/extras/webhooks_worker.py index 6f7ede4e4..d98536b7f 100644 --- a/netbox/extras/webhooks_worker.py +++ b/netbox/extras/webhooks_worker.py @@ -23,7 +23,7 @@ def process_webhook(webhook, data, model_name, event, timestamp, username, reque 'data': data } headers = { - 'Content-Type': webhook.get_http_content_type_display(), + 'Content-Type': webhook.http_content_type, } if webhook.additional_headers: headers.update(webhook.additional_headers) From 2e69037c294884434c6054e5b42ca688bfe9e42d Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 23 Jan 2020 15:04:40 -0500 Subject: [PATCH 14/28] Closes #3952: Add test for webhooks_worker; introduce generate_signature() --- netbox/extras/tests/test_webhooks.py | 62 ++++++++++++++++++++++++++-- netbox/extras/webhooks.py | 14 +++++++ netbox/extras/webhooks_worker.py | 12 ++---- 3 files changed, 75 insertions(+), 13 deletions(-) diff --git a/netbox/extras/tests/test_webhooks.py b/netbox/extras/tests/test_webhooks.py index 02698b7dd..026a82bb8 100644 --- a/netbox/extras/tests/test_webhooks.py +++ b/netbox/extras/tests/test_webhooks.py @@ -1,11 +1,19 @@ +import json +import uuid +from unittest.mock import patch + import django_rq from django.contrib.contenttypes.models import ContentType +from django.http import HttpResponse from django.urls import reverse +from requests import Session from rest_framework import status from dcim.models import Site from extras.choices import ObjectChangeActionChoices from extras.models import Webhook +from extras.webhooks import enqueue_webhooks, generate_signature +from extras.webhooks_worker import process_webhook from utilities.testing import APITestCase @@ -22,11 +30,13 @@ class WebhookTest(APITestCase): def setUpTestData(cls): site_ct = ContentType.objects.get_for_model(Site) - PAYLOAD_URL = "http://localhost/" + DUMMY_URL = "http://localhost/" + DUMMY_SECRET = "LOOKATMEIMASECRETSTRING" + webhooks = Webhook.objects.bulk_create(( - Webhook(name='Site Create Webhook', type_create=True, payload_url=PAYLOAD_URL), - Webhook(name='Site Update Webhook', type_update=True, payload_url=PAYLOAD_URL), - Webhook(name='Site Delete Webhook', type_delete=True, payload_url=PAYLOAD_URL), + Webhook(name='Site Create Webhook', type_create=True, payload_url=DUMMY_URL, secret=DUMMY_SECRET, additional_headers={'X-Foo': 'Bar'}), + Webhook(name='Site Update Webhook', type_update=True, payload_url=DUMMY_URL, secret=DUMMY_SECRET), + Webhook(name='Site Delete Webhook', type_delete=True, payload_url=DUMMY_URL, secret=DUMMY_SECRET), )) for webhook in webhooks: webhook.obj_type.set([site_ct]) @@ -87,3 +97,47 @@ class WebhookTest(APITestCase): self.assertEqual(job.args[1]['id'], site.pk) self.assertEqual(job.args[2], 'site') self.assertEqual(job.args[3], ObjectChangeActionChoices.ACTION_DELETE) + + def test_webhooks_worker(self): + + request_id = uuid.uuid4() + + def dummy_send(_, request): + """ + A dummy implementation of Session.send() to be used for testing. + Always returns a 200 HTTP response. + """ + webhook = Webhook.objects.get(type_create=True) + signature = generate_signature(request.body, webhook.secret) + + # Validate the outgoing request headers + self.assertEqual(request.headers['Content-Type'], webhook.http_content_type) + self.assertEqual(request.headers['X-Hook-Signature'], signature) + self.assertEqual(request.headers['X-Foo'], 'Bar') + + # Validate the outgoing request body + body = json.loads(request.body) + self.assertEqual(body['event'], 'created') + self.assertEqual(body['timestamp'], job.args[4]) + self.assertEqual(body['model'], 'site') + self.assertEqual(body['username'], 'testuser') + self.assertEqual(body['request_id'], str(request_id)) + self.assertEqual(body['data']['name'], 'Site 1') + + return HttpResponse() + + # Enqueue a webhook for processing + site = Site.objects.create(name='Site 1', slug='site-1') + enqueue_webhooks( + instance=site, + user=self.user, + request_id=request_id, + action=ObjectChangeActionChoices.ACTION_CREATE + ) + + # Retrieve the job from queue + job = self.queue.jobs[0] + + # Patch the Session object with our dummy_send() method, then process the webhook for sending + with patch.object(Session, 'send', dummy_send) as mock_send: + process_webhook(*job.args) diff --git a/netbox/extras/webhooks.py b/netbox/extras/webhooks.py index 5017582cc..04eca4dfe 100644 --- a/netbox/extras/webhooks.py +++ b/netbox/extras/webhooks.py @@ -1,4 +1,6 @@ import datetime +import hashlib +import hmac from django.contrib.contenttypes.models import ContentType @@ -8,6 +10,18 @@ from .choices import * from .constants import * +def generate_signature(request_body, secret): + """ + Return a cryptographic signature that can be used to verify the authenticity of webhook data. + """ + hmac_prep = hmac.new( + key=secret.encode('utf8'), + msg=request_body.encode('utf8'), + digestmod=hashlib.sha512 + ) + return hmac_prep.hexdigest() + + def enqueue_webhooks(instance, user, request_id, action): """ Find Webhook(s) assigned to this instance + action and enqueue them diff --git a/netbox/extras/webhooks_worker.py b/netbox/extras/webhooks_worker.py index d98536b7f..e48d8a2d7 100644 --- a/netbox/extras/webhooks_worker.py +++ b/netbox/extras/webhooks_worker.py @@ -1,5 +1,3 @@ -import hashlib -import hmac import json import requests @@ -7,6 +5,7 @@ from django_rq import job from rest_framework.utils.encoders import JSONEncoder from .choices import ObjectChangeActionChoices, WebhookContentTypeChoices +from .webhooks import generate_signature @job('default') @@ -43,12 +42,7 @@ def process_webhook(webhook, data, model_name, event, timestamp, username, reque if webhook.secret != '': # Sign the request with a hash of the secret key and its content. - hmac_prep = hmac.new( - key=webhook.secret.encode('utf8'), - msg=prepared_request.body.encode('utf8'), - digestmod=hashlib.sha512 - ) - prepared_request.headers['X-Hook-Signature'] = hmac_prep.hexdigest() + prepared_request.headers['X-Hook-Signature'] = generate_signature(prepared_request.body, webhook.secret) with requests.Session() as session: session.verify = webhook.ssl_verification @@ -56,7 +50,7 @@ def process_webhook(webhook, data, model_name, event, timestamp, username, reque session.verify = webhook.ca_file_path response = session.send(prepared_request) - if response.status_code >= 200 and response.status_code <= 299: + if 200 <= response.status_code <= 299: return 'Status {} returned, webhook successfully processed.'.format(response.status_code) else: raise requests.exceptions.RequestException( From fcbbb36afc038c29e3cee35dda9c03bca47eee10 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 23 Jan 2020 15:41:09 -0500 Subject: [PATCH 15/28] Add tests for home and search views --- netbox/netbox/tests/__init__.py | 0 netbox/netbox/tests/test_api.py | 13 +++++++++++++ netbox/netbox/tests/test_views.py | 24 ++++++++++++++++++++++++ 3 files changed, 37 insertions(+) create mode 100644 netbox/netbox/tests/__init__.py create mode 100644 netbox/netbox/tests/test_api.py create mode 100644 netbox/netbox/tests/test_views.py diff --git a/netbox/netbox/tests/__init__.py b/netbox/netbox/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/netbox/tests/test_api.py b/netbox/netbox/tests/test_api.py new file mode 100644 index 000000000..0ee2d78dc --- /dev/null +++ b/netbox/netbox/tests/test_api.py @@ -0,0 +1,13 @@ +from django.urls import reverse + +from utilities.testing import APITestCase + + +class AppTest(APITestCase): + + def test_root(self): + + url = reverse('api-root') + response = self.client.get('{}?format=api'.format(url), **self.header) + + self.assertEqual(response.status_code, 200) diff --git a/netbox/netbox/tests/test_views.py b/netbox/netbox/tests/test_views.py new file mode 100644 index 000000000..db84dcd1a --- /dev/null +++ b/netbox/netbox/tests/test_views.py @@ -0,0 +1,24 @@ +import urllib.parse + +from django.test import TestCase +from django.urls import reverse + + +class HomeViewTestCase(TestCase): + + def test_home(self): + + url = reverse('home') + + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + def test_search(self): + + url = reverse('search') + params = { + 'q': 'foo', + } + + response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params))) + self.assertEqual(response.status_code, 200) From fe402331f29e2b8cb9f84293f88e2d085ace20a4 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 23 Jan 2020 16:16:52 -0500 Subject: [PATCH 16/28] Handle grouped choices when returning ChoiceSet values --- netbox/utilities/choices.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/utilities/choices.py b/netbox/utilities/choices.py index 6e6afe3e2..19082dbb6 100644 --- a/netbox/utilities/choices.py +++ b/netbox/utilities/choices.py @@ -18,7 +18,7 @@ class ChoiceSet(metaclass=ChoiceSetMeta): @classmethod def values(cls): - return [c[0] for c in cls.CHOICES] + return [c[0] for c in unpack_grouped_choices(cls.CHOICES)] @classmethod def as_dict(cls): From cdecf93f008bbe7d77d713df4d8c3a4f9a0e5ad8 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 23 Jan 2020 16:19:34 -0500 Subject: [PATCH 17/28] Add tests for ChoiceSet --- netbox/utilities/tests/test_choices.py | 50 ++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 netbox/utilities/tests/test_choices.py diff --git a/netbox/utilities/tests/test_choices.py b/netbox/utilities/tests/test_choices.py new file mode 100644 index 000000000..adff4eb60 --- /dev/null +++ b/netbox/utilities/tests/test_choices.py @@ -0,0 +1,50 @@ +from django.test import TestCase + +from utilities.choices import ChoiceSet + + +class ExampleChoices(ChoiceSet): + + CHOICE_A = 'a' + CHOICE_B = 'b' + CHOICE_C = 'c' + CHOICE_1 = 1 + CHOICE_2 = 2 + CHOICE_3 = 3 + CHOICES = ( + ('Letters', ( + (CHOICE_A, 'A'), + (CHOICE_B, 'B'), + (CHOICE_C, 'C'), + )), + ('Digits', ( + (CHOICE_1, 'One'), + (CHOICE_2, 'Two'), + (CHOICE_3, 'Three'), + )), + ) + LEGACY_MAP = { + CHOICE_A: 101, + CHOICE_B: 102, + CHOICE_C: 103, + CHOICE_1: 201, + CHOICE_2: 202, + CHOICE_3: 203, + } + + +class ChoiceSetTestCase(TestCase): + + def test_values(self): + self.assertListEqual(ExampleChoices.values(), ['a', 'b', 'c', 1, 2, 3]) + + def test_as_dict(self): + self.assertEqual(ExampleChoices.as_dict(), { + 'a': 'A', 'b': 'B', 'c': 'C', 1: 'One', 2: 'Two', 3: 'Three' + }) + + def test_slug_to_id(self): + self.assertEqual(ExampleChoices.slug_to_id('a'), 101) + + def test_id_to_slug(self): + self.assertEqual(ExampleChoices.id_to_slug(101), 'a') From 629712142facb13ecb7994cd3a6d495bab4d213e Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 23 Jan 2020 17:11:45 -0500 Subject: [PATCH 18/28] Fixes #3999: Do not filter child results by null if non-required parent fields are blank --- docs/release-notes/version-2.7.md | 1 + netbox/project-static/js/forms.js | 11 +++++++---- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index 6df63fb69..5ca486137 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -9,6 +9,7 @@ * [#3983](https://github.com/netbox-community/netbox/issues/3983) - Permit the creation of multiple unnamed devices * [#3989](https://github.com/netbox-community/netbox/issues/3989) - Correct HTTP content type assignment for webhooks +* [#3999](https://github.com/netbox-community/netbox/issues/3999) - Do not filter child results by null if non-required parent fields are blank --- diff --git a/netbox/project-static/js/forms.js b/netbox/project-static/js/forms.js index 58783b5d0..b1ba8a37c 100644 --- a/netbox/project-static/js/forms.js +++ b/netbox/project-static/js/forms.js @@ -158,14 +158,17 @@ $(document).ready(function() { filter_for_elements.each(function(index, filter_for_element) { var param_name = $(filter_for_element).attr(attr_name); + var is_required = $(filter_for_element).attr("required"); var is_nullable = $(filter_for_element).attr("nullable"); var is_visible = $(filter_for_element).is(":visible"); var value = $(filter_for_element).val(); - if (param_name && is_visible && value) { - parameters[param_name] = value; - } else if (param_name && is_visible && is_nullable) { - parameters[param_name] = "null"; + if (param_name && is_visible) { + if (value) { + parameters[param_name] = value; + } else if (is_required && is_nullable) { + parameters[param_name] = "null"; + } } }); From fcba2baf42e90bff787e0aa8e066895a4920794d Mon Sep 17 00:00:00 2001 From: hellerve Date: Fri, 24 Jan 2020 08:45:55 +0100 Subject: [PATCH 19/28] dcim: fix #3982 by readding reserved tooltip --- netbox/dcim/models/__init__.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/netbox/dcim/models/__init__.py b/netbox/dcim/models/__init__.py index 1c9c8682d..92cec5729 100644 --- a/netbox/dcim/models/__init__.py +++ b/netbox/dcim/models/__init__.py @@ -414,7 +414,7 @@ class RackElevationHelperMixin: drawing.add(drawing.text(str(device), insert=text)) @staticmethod - def _draw_empty(drawing, rack, start, end, text, id_, face_id, class_): + def _draw_empty(drawing, rack, start, end, text, id_, face_id, class_, reservation): link = drawing.add( drawing.a( href='{}?{}'.format( @@ -424,6 +424,10 @@ class RackElevationHelperMixin: target='_top' ) ) + if reservation: + link.set_desc('{} — {} · {}'.format( + reservation.description, reservation.user, reservation.created + )) link.add(drawing.rect(start, end, class_=class_)) link.add(drawing.text("add device", insert=text, class_='add-device')) @@ -453,12 +457,13 @@ class RackElevationHelperMixin: else: # Draw shallow devices, reservations, or empty units class_ = 'slot' + reservation = reserved_units.get(unit["id"]) if device: class_ += ' occupied' - if unit["id"] in reserved_units: + if reservation: class_ += ' reserved' self._draw_empty( - drawing, self, start_cordinates, end_cordinates, text_cordinates, unit["id"], face, class_ + drawing, self, start_cordinates, end_cordinates, text_cordinates, unit["id"], face, class_, reservation ) unit_cursor += height @@ -493,7 +498,7 @@ class RackElevationHelperMixin: height of the elevation """ elevation = self.merge_elevations(face) - reserved_units = self.get_reserved_units().keys() + reserved_units = self.get_reserved_units() return self._draw_elevations(elevation, reserved_units, face, unit_width, unit_height) From 9694bacb69c82c5ed543754f436f2e09c4be890c Mon Sep 17 00:00:00 2001 From: kobayashi Date: Sat, 18 Jan 2020 07:37:06 -0500 Subject: [PATCH 20/28] 3950 not retain device type --- docs/release-notes/version-2.7.md | 1 + netbox/dcim/forms.py | 1 + netbox/dcim/models/__init__.py | 2 +- netbox/utilities/utils.py | 22 +++++++++++++++++++--- 4 files changed, 22 insertions(+), 4 deletions(-) diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index 5ca486137..2e5804c08 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -26,6 +26,7 @@ * [#3721](https://github.com/netbox-community/netbox/issues/3721) - Allow Unicode characters in tag slugs * [#3923](https://github.com/netbox-community/netbox/issues/3923) - Indicate validation failure when using SSH-style RSA keys * [#3951](https://github.com/netbox-community/netbox/issues/3951) - Fix exception in webhook worker due to missing constant +* [#3950](https://github.com/netbox-community/netbox/issues/3950) - Fix "Create and Add Another" not keep Manufacturer and Device Type after device creation * [#3953](https://github.com/netbox-community/netbox/issues/3953) - Fix validation error when creating child devices * [#3960](https://github.com/netbox-community/netbox/issues/3960) - Fix legacy device status choice * [#3962](https://github.com/netbox-community/netbox/issues/3962) - Fix display of unnamed devices in rack elevations diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 84d83a94c..2edf93857 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -1636,6 +1636,7 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm): instance = kwargs.get('instance') if 'initial' not in kwargs: kwargs['initial'] = {} + # Using hasattr() instead of "is not None" to avoid RelatedObjectDoesNotExist on required field if instance and hasattr(instance, 'device_type'): kwargs['initial']['manufacturer'] = instance.device_type.manufacturer diff --git a/netbox/dcim/models/__init__.py b/netbox/dcim/models/__init__.py index 1c9c8682d..3827716bd 100644 --- a/netbox/dcim/models/__init__.py +++ b/netbox/dcim/models/__init__.py @@ -1409,7 +1409,7 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): 'site', 'rack_group', 'rack_name', 'position', 'face', 'comments', ] clone_fields = [ - 'device_type', 'device_role', 'tenant', 'platform', 'site', 'rack', 'status', 'cluster', + 'device_type', 'device_type__manufacturer', 'device_role', 'tenant', 'platform', 'site', 'rack', 'status', 'cluster', 'cluster__group', ] STATUS_CLASS_MAP = { diff --git a/netbox/utilities/utils.py b/netbox/utilities/utils.py index dc2185988..82db56c10 100644 --- a/netbox/utilities/utils.py +++ b/netbox/utilities/utils.py @@ -181,15 +181,31 @@ def render_jinja2(template_code, context): return Environment().from_string(source=template_code).render(**context) +def get_field_value(instance, field_name): + """ + Retrieve appropriate field name & value from object recursively. + """ + if '__' in field_name: + fn = field_name.split('__') + attr = getattr(instance, fn[0]) + return get_field_value(attr, fn[1]) + + field = instance._meta.get_field(field_name) if instance else None + field_value = field.value_from_object(instance) if field else None + + if field_name == 'group': + field_name = 'cluster_group' + return field_name, field_value + + def prepare_cloned_fields(instance): """ Compile an object's `clone_fields` list into a string of URL query parameters. Tags are automatically cloned where applicable. """ params = {} - for field_name in getattr(instance, 'clone_fields', []): - field = instance._meta.get_field(field_name) - field_value = field.value_from_object(instance) + for field in getattr(instance, 'clone_fields', []): + field_name, field_value = get_field_value(instance, field) # Swap out False with URL-friendly value if field_value is False: From 66d5cc47a5c17387f805968f361ec28cfc6c0cfc Mon Sep 17 00:00:00 2001 From: kobayashi Date: Fri, 24 Jan 2020 03:11:17 -0500 Subject: [PATCH 21/28] Fixes #3950: Cloned Device Form does not retain device type --- docs/release-notes/version-2.7.md | 2 +- netbox/dcim/fixtures/dcim.json | 58 ++++++ netbox/dcim/forms.py | 11 +- netbox/dcim/models/__init__.py | 2 +- netbox/dcim/tests/test_forms.py | 14 +- netbox/utilities/utils.py | 22 +-- .../fixtures/virtualization.json | 170 ++++++++++++++++++ 7 files changed, 256 insertions(+), 23 deletions(-) create mode 100644 netbox/virtualization/fixtures/virtualization.json diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index 2e5804c08..5e0b5d744 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -7,6 +7,7 @@ ## Bug Fixes +* [#3950](https://github.com/netbox-community/netbox/issues/3950) - Fix "Create and Add Another" not keep Manufacturer and Device Type after device creation * [#3983](https://github.com/netbox-community/netbox/issues/3983) - Permit the creation of multiple unnamed devices * [#3989](https://github.com/netbox-community/netbox/issues/3989) - Correct HTTP content type assignment for webhooks * [#3999](https://github.com/netbox-community/netbox/issues/3999) - Do not filter child results by null if non-required parent fields are blank @@ -26,7 +27,6 @@ * [#3721](https://github.com/netbox-community/netbox/issues/3721) - Allow Unicode characters in tag slugs * [#3923](https://github.com/netbox-community/netbox/issues/3923) - Indicate validation failure when using SSH-style RSA keys * [#3951](https://github.com/netbox-community/netbox/issues/3951) - Fix exception in webhook worker due to missing constant -* [#3950](https://github.com/netbox-community/netbox/issues/3950) - Fix "Create and Add Another" not keep Manufacturer and Device Type after device creation * [#3953](https://github.com/netbox-community/netbox/issues/3953) - Fix validation error when creating child devices * [#3960](https://github.com/netbox-community/netbox/issues/3960) - Fix legacy device status choice * [#3962](https://github.com/netbox-community/netbox/issues/3962) - Fix display of unnamed devices in rack elevations diff --git a/netbox/dcim/fixtures/dcim.json b/netbox/dcim/fixtures/dcim.json index b9f41edb5..2b379b9ff 100644 --- a/netbox/dcim/fixtures/dcim.json +++ b/netbox/dcim/fixtures/dcim.json @@ -66,6 +66,14 @@ "slug": "servertech" } }, +{ + "model": "dcim.manufacturer", + "pk": 4, + "fields": { + "name": "Dell", + "slug": "dell" + } +}, { "model": "dcim.devicetype", "pk": 1, @@ -144,6 +152,19 @@ "is_full_depth": false } }, +{ + "model": "dcim.devicetype", + "pk": 7, + "fields": { + "created": "2016-06-23", + "last_updated": "2016-06-23T03:19:56.521Z", + "manufacturer": 4, + "model": "PowerEdge R640", + "slug": "poweredge-r640", + "u_height": 1, + "is_full_depth": false + } +}, { "model": "dcim.consoleporttemplate", "pk": 1, @@ -1880,6 +1901,15 @@ "color": "yellow" } }, +{ + "model": "dcim.devicerole", + "pk": 7, + "fields": { + "name": "Server", + "slug": "server", + "color": "grey" + } +}, { "model": "dcim.platform", "pk": 1, @@ -2127,6 +2157,34 @@ "comments": "" } }, +{ + "model": "dcim.device", + "pk": 13, + "fields": { + "local_context_data": null, + "created": "2016-06-23", + "last_updated": "2016-06-23T03:19:56.521Z", + "device_type": 7, + "device_role": 6, + "tenant": null, + "platform": null, + "name": "test1-server1", + "serial": "", + "asset_tag": null, + "site": 1, + "rack": 2, + "position": null, + "face": "", + "status": true, + "primary_ip4": null, + "primary_ip6": null, + "cluster": 4, + "virtual_chassis": null, + "vc_position": null, + "vc_priority": null, + "comments": "" + } +}, { "model": "dcim.consoleport", "pk": 1, diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 2edf93857..79fe13a9a 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -1636,13 +1636,22 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm): instance = kwargs.get('instance') if 'initial' not in kwargs: kwargs['initial'] = {} - # Using hasattr() instead of "is not None" to avoid RelatedObjectDoesNotExist on required field if instance and hasattr(instance, 'device_type'): kwargs['initial']['manufacturer'] = instance.device_type.manufacturer if instance and instance.cluster is not None: kwargs['initial']['cluster_group'] = instance.cluster.group + if 'device_type' in kwargs['initial'] and 'manufacturer' not in kwargs['initial']: + device_type_id = kwargs['initial']['device_type'] + manufacturer_id = DeviceType.objects.filter(pk=device_type_id).values_list('manufacturer__pk', flat=True).first() + kwargs['initial']['manufacturer'] = manufacturer_id + + if 'cluster' in kwargs['initial'] and 'cluster_group' not in kwargs['initial']: + cluster_id = kwargs['initial']['cluster'] + cluster_group_id = Cluster.objects.filter(pk=cluster_id).values_list('group__pk', flat=True).first() + kwargs['initial']['cluster_group'] = cluster_group_id + super().__init__(*args, **kwargs) if self.instance.pk: diff --git a/netbox/dcim/models/__init__.py b/netbox/dcim/models/__init__.py index 3827716bd..1c9c8682d 100644 --- a/netbox/dcim/models/__init__.py +++ b/netbox/dcim/models/__init__.py @@ -1409,7 +1409,7 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): 'site', 'rack_group', 'rack_name', 'position', 'face', 'comments', ] clone_fields = [ - 'device_type', 'device_type__manufacturer', 'device_role', 'tenant', 'platform', 'site', 'rack', 'status', 'cluster', 'cluster__group', + 'device_type', 'device_role', 'tenant', 'platform', 'site', 'rack', 'status', 'cluster', ] STATUS_CLASS_MAP = { diff --git a/netbox/dcim/tests/test_forms.py b/netbox/dcim/tests/test_forms.py index d7a946568..5bbe36716 100644 --- a/netbox/dcim/tests/test_forms.py +++ b/netbox/dcim/tests/test_forms.py @@ -10,7 +10,7 @@ def get_id(model, slug): class DeviceTestCase(TestCase): - fixtures = ['dcim', 'ipam'] + fixtures = ['dcim', 'ipam', 'virtualization'] def test_racked_device(self): test = DeviceForm(data={ @@ -78,3 +78,15 @@ class DeviceTestCase(TestCase): }) self.assertTrue(test.is_valid()) self.assertTrue(test.save()) + + def test_cloned_cluster_device_initial_data(self): + test = DeviceForm(initial={ + 'device_type': get_id(DeviceType, 'poweredge-r640'), + 'device_role': get_id(DeviceRole, 'server'), + 'status': DeviceStatusChoices.STATUS_ACTIVE, + 'site': get_id(Site, 'test1'), + "cluster": Cluster.objects.get(id=4).id, + }) + self.assertEqual(test.initial['manufacturer'], get_id(Manufacturer, 'dell')) + self.assertIn('cluster_group', test.initial) + self.assertEqual(test.initial['cluster_group'], get_id(ClusterGroup, 'vm-host')) diff --git a/netbox/utilities/utils.py b/netbox/utilities/utils.py index 82db56c10..dc2185988 100644 --- a/netbox/utilities/utils.py +++ b/netbox/utilities/utils.py @@ -181,31 +181,15 @@ def render_jinja2(template_code, context): return Environment().from_string(source=template_code).render(**context) -def get_field_value(instance, field_name): - """ - Retrieve appropriate field name & value from object recursively. - """ - if '__' in field_name: - fn = field_name.split('__') - attr = getattr(instance, fn[0]) - return get_field_value(attr, fn[1]) - - field = instance._meta.get_field(field_name) if instance else None - field_value = field.value_from_object(instance) if field else None - - if field_name == 'group': - field_name = 'cluster_group' - return field_name, field_value - - def prepare_cloned_fields(instance): """ Compile an object's `clone_fields` list into a string of URL query parameters. Tags are automatically cloned where applicable. """ params = {} - for field in getattr(instance, 'clone_fields', []): - field_name, field_value = get_field_value(instance, field) + for field_name in getattr(instance, 'clone_fields', []): + field = instance._meta.get_field(field_name) + field_value = field.value_from_object(instance) # Swap out False with URL-friendly value if field_value is False: diff --git a/netbox/virtualization/fixtures/virtualization.json b/netbox/virtualization/fixtures/virtualization.json new file mode 100644 index 000000000..3c9537802 --- /dev/null +++ b/netbox/virtualization/fixtures/virtualization.json @@ -0,0 +1,170 @@ +[ +{ + "model": "virtualization.clustertype", + "pk": 1, + "fields": { + "created": "2016-08-01", + "last_updated": "2016-08-01T15:22:42.289Z", + "name": "Public Cloud", + "slug": "public-cloud" + } +}, +{ + "model": "virtualization.clustertype", + "pk": 2, + "fields": { + "created": "2016-08-01", + "last_updated": "2016-08-01T15:22:42.289Z", + "name": "vSphere", + "slug": "vsphere" + } +}, +{ + "model": "virtualization.clustertype", + "pk": 3, + "fields": { + "created": "2016-08-01", + "last_updated": "2016-08-01T15:22:42.289Z", + "name": "Hyper-V", + "slug": "hyper-v" + } +}, +{ + "model": "virtualization.clustertype", + "pk": 4, + "fields": { + "created": "2016-08-01", + "last_updated": "2016-08-01T15:22:42.289Z", + "name": "libvirt", + "slug": "libvirt" + } +}, +{ + "model": "virtualization.clustertype", + "pk": 5, + "fields": { + "created": "2016-08-01", + "last_updated": "2016-08-01T15:22:42.289Z", + "name": "LXD", + "slug": "lxd" + } +}, +{ + "model": "virtualization.clustertype", + "pk": 6, + "fields": { + "created": "2016-08-01", + "last_updated": "2016-08-01T15:22:42.289Z", + "name": "Docker", + "slug": "docker" + } +}, +{ + "model": "virtualization.clustergroup", + "pk": 1, + "fields": { + "created": "2016-08-01", + "last_updated": "2016-08-01T15:22:42.289Z", + "name": "VM Host", + "slug": "vm-host" + } +}, +{ + "model": "virtualization.cluster", + "pk": 1, + "fields": { + "created": "2016-08-01", + "last_updated": "2016-08-01T15:22:42.289Z", + "name": "Digital Ocean", + "type": 1, + "group": 1, + "tenant": null, + "site": null, + "comments": "" + } +}, +{ + "model": "virtualization.cluster", + "pk": 2, + "fields": { + "created": "2016-08-01", + "last_updated": "2016-08-01T15:22:42.289Z", + "name": "Amazon EC2", + "type": 1, + "group": 1, + "tenant": null, + "site": null, + "comments": "" + } +}, +{ + "model": "virtualization.cluster", + "pk": 3, + "fields": { + "created": "2016-08-01", + "last_updated": "2016-08-01T15:22:42.289Z", + "name": "Microsoft Azure", + "type": 1, + "group": 1, + "tenant": null, + "site": null, + "comments": "" + } +}, +{ + "model": "virtualization.cluster", + "pk": 4, + "fields": { + "created": "2016-08-01", + "last_updated": "2016-08-01T15:22:42.289Z", + "name": "vSphere Cluster", + "type": 2, + "group": 1, + "tenant": null, + "site": null, + "comments": "" + } +}, +{ + "model": "virtualization.virtualmachine", + "pk": 1, + "fields": { + "local_context_data": null, + "created": "2019-12-19", + "last_updated": "2019-12-19T05:24:19.146Z", + "cluster": 2, + "tenant": null, + "platform": null, + "name": "vm1", + "status": "active", + "role": null, + "primary_ip4": null, + "primary_ip6": null, + "vcpus": null, + "memory": null, + "disk": null, + "comments": "" + } +}, +{ + "model": "virtualization.virtualmachine", + "pk": 2, + "fields": { + "local_context_data": null, + "created": "2019-12-19", + "last_updated": "2019-12-19T05:24:41.478Z", + "cluster": 1, + "tenant": null, + "platform": null, + "name": "vm2", + "status": "active", + "role": null, + "primary_ip4": null, + "primary_ip6": null, + "vcpus": null, + "memory": null, + "disk": null, + "comments": "" + } +} +] From d3463b596ae5f196af922e72225a68f7f56f6b1c Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 24 Jan 2020 12:00:24 -0500 Subject: [PATCH 22/28] Closes #4005: Include timezone context in webhook timestamps --- docs/release-notes/version-2.7.md | 1 + netbox/extras/webhooks.py | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index 5ca486137..c8e47fa15 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -4,6 +4,7 @@ * [#3310](https://github.com/netbox-community/netbox/issues/3310) - Pre-select site/rack for B side when creating a new cable * [#3509](https://github.com/netbox-community/netbox/issues/3509) - Add IP address variables for custom scripts +* [#4005](https://github.com/netbox-community/netbox/issues/4005) - Include timezone context in webhook timestamps ## Bug Fixes diff --git a/netbox/extras/webhooks.py b/netbox/extras/webhooks.py index 04eca4dfe..cfa05d0f6 100644 --- a/netbox/extras/webhooks.py +++ b/netbox/extras/webhooks.py @@ -3,6 +3,7 @@ import hashlib import hmac from django.contrib.contenttypes.models import ContentType +from django.utils import timezone from extras.models import Webhook from utilities.api import get_serializer_for_model @@ -62,7 +63,7 @@ def enqueue_webhooks(instance, user, request_id, action): serializer.data, instance._meta.model_name, action, - str(datetime.datetime.now()), + str(timezone.now()), user.username, request_id ) From f24e7652a867c748d4848c5df5978256786c7d78 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 24 Jan 2020 12:10:38 -0500 Subject: [PATCH 23/28] Add changelog for #3982 --- docs/release-notes/version-2.7.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index c8e47fa15..afa43cade 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -8,6 +8,7 @@ ## Bug Fixes +* [#3982](https://github.com/netbox-community/netbox/issues/3982) - Restore tooltip for reservations on rack elevations * [#3983](https://github.com/netbox-community/netbox/issues/3983) - Permit the creation of multiple unnamed devices * [#3989](https://github.com/netbox-community/netbox/issues/3989) - Correct HTTP content type assignment for webhooks * [#3999](https://github.com/netbox-community/netbox/issues/3999) - Do not filter child results by null if non-required parent fields are blank From 096814dc33524e0f2300c74ecb419b142da8ce2d Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 24 Jan 2020 14:21:59 -0500 Subject: [PATCH 24/28] #3880: Define constants for arbitrary values --- netbox/dcim/constants.py | 26 ++++++++++++++++++++++-- netbox/dcim/forms.py | 25 +++++++++++------------ netbox/dcim/models/__init__.py | 15 +++++++++----- netbox/ipam/constants.py | 37 +++++++++++++++++++++++++++++++++- netbox/ipam/forms.py | 13 ++++++------ netbox/ipam/models.py | 6 +++--- netbox/ipam/views.py | 16 +++++++-------- netbox/secrets/constants.py | 5 +++++ netbox/secrets/forms.py | 5 +++-- netbox/utilities/api.py | 1 - netbox/virtualization/forms.py | 13 ++++++------ 11 files changed, 114 insertions(+), 48 deletions(-) create mode 100644 netbox/secrets/constants.py diff --git a/netbox/dcim/constants.py b/netbox/dcim/constants.py index 3a6f8e5e9..0301da7d8 100644 --- a/netbox/dcim/constants.py +++ b/netbox/dcim/constants.py @@ -4,17 +4,30 @@ from .choices import InterfaceTypeChoices # -# Rack elevation rendering +# Racks # +RACK_U_HEIGHT_DEFAULT = 42 + RACK_ELEVATION_UNIT_WIDTH_DEFAULT = 230 RACK_ELEVATION_UNIT_HEIGHT_DEFAULT = 20 # -# Interface type groups +# RearPorts # +REARPORT_POSITIONS_MIN = 1 +REARPORT_POSITIONS_MAX = 64 + + +# +# Interfaces +# + +INTERFACE_MTU_MIN = 1 +INTERFACE_MTU_MAX = 32767 + VIRTUAL_IFACE_TYPES = [ InterfaceTypeChoices.TYPE_VIRTUAL, InterfaceTypeChoices.TYPE_LAG, @@ -31,6 +44,15 @@ WIRELESS_IFACE_TYPES = [ NONCONNECTABLE_IFACE_TYPES = VIRTUAL_IFACE_TYPES + WIRELESS_IFACE_TYPES +# +# PowerFeeds +# + +POWERFEED_VOLTAGE_DEFAULT = 120 +POWERFEED_AMPERAGE_DEFAULT = 20 +POWERFEED_MAX_UTILIZATION_DEFAULT = 80 # Percentage + + # # Cabling and connections # diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 79fe13a9a..f0667c143 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -5,7 +5,6 @@ from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType from django.contrib.postgres.forms.array import SimpleArrayField from django.core.exceptions import ObjectDoesNotExist -from django.db.models import Q from mptt.forms import TreeNodeChoiceField from netaddr import EUI from netaddr.core import AddrFormatError @@ -1301,8 +1300,8 @@ class RearPortTemplateCreateForm(ComponentForm): widget=StaticSelect2(), ) positions = forms.IntegerField( - min_value=1, - max_value=64, + min_value=REARPORT_POSITIONS_MIN, + max_value=REARPORT_POSITIONS_MAX, initial=1, help_text='The number of front ports which may be mapped to each rear port' ) @@ -2133,8 +2132,8 @@ class DeviceBulkAddInterfaceForm(DeviceBulkAddComponentForm): ) mtu = forms.IntegerField( required=False, - min_value=1, - max_value=32767, + min_value=INTERFACE_MTU_MIN, + max_value=INTERFACE_MTU_MAX, label='MTU' ) mgmt_only = forms.BooleanField( @@ -2620,8 +2619,8 @@ class InterfaceCreateForm(InterfaceCommonForm, ComponentForm, forms.Form): ) mtu = forms.IntegerField( required=False, - min_value=1, - max_value=32767, + min_value=INTERFACE_MTU_MIN, + max_value=INTERFACE_MTU_MAX, label='MTU' ) mac_address = forms.CharField( @@ -2775,8 +2774,8 @@ class InterfaceBulkEditForm(InterfaceCommonForm, BootstrapMixin, AddRemoveTagsFo ) mtu = forms.IntegerField( required=False, - min_value=1, - max_value=32767, + min_value=INTERFACE_MTU_MIN, + max_value=INTERFACE_MTU_MAX, label='MTU' ) mgmt_only = forms.NullBooleanField( @@ -3055,8 +3054,8 @@ class RearPortCreateForm(ComponentForm): widget=StaticSelect2(), ) positions = forms.IntegerField( - min_value=1, - max_value=64, + min_value=REARPORT_POSITIONS_MIN, + max_value=REARPORT_POSITIONS_MAX, initial=1, help_text='The number of front ports which may be mapped to each rear port' ) @@ -3533,7 +3532,7 @@ class CableBulkEditForm(BootstrapMixin, BulkEditForm): required=False ) color = forms.CharField( - max_length=6, + max_length=6, # RGB color code required=False, widget=ColorSelect() ) @@ -3612,7 +3611,7 @@ class CableFilterForm(BootstrapMixin, forms.Form): widget=StaticSelect2() ) color = forms.CharField( - max_length=6, + max_length=6, # RGB color code required=False, widget=ColorSelect() ) diff --git a/netbox/dcim/models/__init__.py b/netbox/dcim/models/__init__.py index 92cec5729..37ee0a266 100644 --- a/netbox/dcim/models/__init__.py +++ b/netbox/dcim/models/__init__.py @@ -488,7 +488,12 @@ class RackElevationHelperMixin: return elevation - def get_elevation_svg(self, face=DeviceFaceChoices.FACE_FRONT, unit_width=230, unit_height=20): + def get_elevation_svg( + self, + face=DeviceFaceChoices.FACE_FRONT, + unit_width=RACK_ELEVATION_UNIT_WIDTH_DEFAULT, + unit_height=RACK_ELEVATION_UNIT_HEIGHT_DEFAULT + ): """ Return an SVG of the rack elevation @@ -574,7 +579,7 @@ class Rack(ChangeLoggedModel, CustomFieldModel, RackElevationHelperMixin): help_text='Rail-to-rail width' ) u_height = models.PositiveSmallIntegerField( - default=42, + default=RACK_U_HEIGHT_DEFAULT, verbose_name='Height (U)', validators=[MinValueValidator(1), MaxValueValidator(100)] ) @@ -1864,15 +1869,15 @@ class PowerFeed(ChangeLoggedModel, CableTermination, CustomFieldModel): ) voltage = models.PositiveSmallIntegerField( validators=[MinValueValidator(1)], - default=120 + default=POWERFEED_VOLTAGE_DEFAULT ) amperage = models.PositiveSmallIntegerField( validators=[MinValueValidator(1)], - default=20 + default=POWERFEED_AMPERAGE_DEFAULT ) max_utilization = models.PositiveSmallIntegerField( validators=[MinValueValidator(1), MaxValueValidator(100)], - default=80, + default=POWERFEED_MAX_UTILIZATION_DEFAULT, help_text="Maximum permissible draw (percentage)" ) available_power = models.PositiveIntegerField( diff --git a/netbox/ipam/constants.py b/netbox/ipam/constants.py index cf6eb2a2a..22a977371 100644 --- a/netbox/ipam/constants.py +++ b/netbox/ipam/constants.py @@ -4,10 +4,29 @@ from .choices import IPAddressRoleChoices BGP_ASN_MIN = 1 BGP_ASN_MAX = 2**32 - 1 + # -# IP addresses +# VRFs # +VRF_RD_MAX_LENGTH = 21 + + +# +# Prefixes +# + +PREFIX_LENGTH_MIN = 1 +PREFIX_LENGTH_MAX = 127 # IPv6 + + +# +# IPAddresses +# + +IPADDRESS_MASK_LENGTH_MIN = 1 +IPADDRESS_MASK_LENGTH_MAX = 128 # IPv6 + IPADDRESS_ROLES_NONUNIQUE = ( # IPAddress roles which are exempt from unique address enforcement IPAddressRoleChoices.ROLE_ANYCAST, @@ -17,3 +36,19 @@ IPADDRESS_ROLES_NONUNIQUE = ( IPAddressRoleChoices.ROLE_GLBP, IPAddressRoleChoices.ROLE_CARP, ) + + +# +# VLANs +# + +VLAN_VID_MIN = 1 +VLAN_VID_MAX = 4094 + + +# +# Services +# + +SERVICE_PORT_MIN = 1 +SERVICE_PORT_MAX = 65535 diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 265ddcb7b..d175fee4a 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -13,6 +13,7 @@ from utilities.forms import ( SlugField, StaticSelect2, StaticSelect2Multiple, BOOLEAN_WITH_BLANK_CHOICES ) from virtualization.models import VirtualMachine +from .constants import * from .choices import * from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF @@ -450,8 +451,8 @@ class PrefixBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF ) ) prefix_length = forms.IntegerField( - min_value=1, - max_value=127, + min_value=PREFIX_LENGTH_MIN, + max_value=PREFIX_LENGTH_MAX, required=False ) tenant = forms.ModelChoiceField( @@ -896,8 +897,8 @@ class IPAddressBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEd ) ) mask_length = forms.IntegerField( - min_value=1, - max_value=128, + min_value=IPADDRESS_MASK_LENGTH_MIN, + max_value=IPADDRESS_MASK_LENGTH_MAX, required=False ) tenant = forms.ModelChoiceField( @@ -1300,8 +1301,8 @@ class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): class ServiceForm(BootstrapMixin, CustomFieldForm): port = forms.IntegerField( - min_value=1, - max_value=65535 + min_value=SERVICE_PORT_MIN, + max_value=SERVICE_PORT_MAX ) tags = TagField( required=False diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index 885d5d617..b4ba92fb5 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -14,7 +14,7 @@ from utilities.models import ChangeLoggedModel from utilities.utils import serialize_object from virtualization.models import VirtualMachine from .choices import * -from .constants import IPADDRESS_ROLES_NONUNIQUE +from .constants import * from .fields import IPNetworkField, IPAddressField from .managers import IPAddressManager from .querysets import PrefixQuerySet @@ -44,7 +44,7 @@ class VRF(ChangeLoggedModel, CustomFieldModel): max_length=50 ) rd = models.CharField( - max_length=21, + max_length=VRF_RD_MAX_LENGTH, unique=True, blank=True, null=True, @@ -1006,7 +1006,7 @@ class Service(ChangeLoggedModel, CustomFieldModel): choices=ServiceProtocolChoices ) port = models.PositiveIntegerField( - validators=[MinValueValidator(1), MaxValueValidator(65535)], + validators=[MinValueValidator(SERVICE_PORT_MIN), MaxValueValidator(SERVICE_PORT_MAX)], verbose_name='Port number' ) ipaddresses = models.ManyToManyField( diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 5cbd55bf4..c8c7d40ca 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -15,6 +15,7 @@ from utilities.views import ( from virtualization.models import VirtualMachine from . import filters, forms, tables from .choices import * +from .constants import * from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF @@ -86,23 +87,20 @@ def add_available_vlans(vlan_group, vlans): """ Create fake records for all gaps between used VLANs """ - MIN_VLAN = 1 - MAX_VLAN = 4094 - if not vlans: - return [{'vid': MIN_VLAN, 'available': MAX_VLAN - MIN_VLAN + 1}] + return [{'vid': VLAN_VID_MIN, 'available': VLAN_VID_MAX - VLAN_VID_MIN + 1}] - prev_vid = MAX_VLAN + prev_vid = VLAN_VID_MAX new_vlans = [] for vlan in vlans: if vlan.vid - prev_vid > 1: new_vlans.append({'vid': prev_vid + 1, 'available': vlan.vid - prev_vid - 1}) prev_vid = vlan.vid - if vlans[0].vid > MIN_VLAN: - new_vlans.append({'vid': MIN_VLAN, 'available': vlans[0].vid - MIN_VLAN}) - if prev_vid < MAX_VLAN: - new_vlans.append({'vid': prev_vid + 1, 'available': MAX_VLAN - prev_vid}) + if vlans[0].vid > VLAN_VID_MIN: + new_vlans.append({'vid': VLAN_VID_MIN, 'available': vlans[0].vid - VLAN_VID_MIN}) + if prev_vid < VLAN_VID_MAX: + new_vlans.append({'vid': prev_vid + 1, 'available': VLAN_VID_MAX - prev_vid}) vlans = list(vlans) + new_vlans vlans.sort(key=lambda v: v.vid if type(v) == VLAN else v['vid']) diff --git a/netbox/secrets/constants.py b/netbox/secrets/constants.py new file mode 100644 index 000000000..a1c3cb3da --- /dev/null +++ b/netbox/secrets/constants.py @@ -0,0 +1,5 @@ +# +# Secrets +# + +SECRET_PLAINTEXT_MAX_LENGTH = 65535 diff --git a/netbox/secrets/forms.py b/netbox/secrets/forms.py index c937e6c92..3b81f9586 100644 --- a/netbox/secrets/forms.py +++ b/netbox/secrets/forms.py @@ -9,6 +9,7 @@ from utilities.forms import ( APISelect, APISelectMultiple, BootstrapMixin, FilterChoiceField, FlexibleModelChoiceField, SlugField, StaticSelect2Multiple ) +from .constants import * from .models import Secret, SecretRole, UserKey @@ -69,7 +70,7 @@ class SecretRoleCSVForm(forms.ModelForm): class SecretForm(BootstrapMixin, CustomFieldForm): plaintext = forms.CharField( - max_length=65535, + max_length=SECRET_PLAINTEXT_MAX_LENGTH, required=False, label='Plaintext', widget=forms.PasswordInput( @@ -79,7 +80,7 @@ class SecretForm(BootstrapMixin, CustomFieldForm): ) ) plaintext2 = forms.CharField( - max_length=65535, + max_length=SECRET_PLAINTEXT_MAX_LENGTH, required=False, label='Plaintext (verify)', widget=forms.PasswordInput() diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py index 7c37a5b20..5ef4156aa 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -13,7 +13,6 @@ from rest_framework.response import Response from rest_framework.serializers import Field, ModelSerializer, ValidationError from rest_framework.viewsets import ModelViewSet as _ModelViewSet, ViewSet -from utilities.choices import ChoiceSet from .utils import dict_to_filter_params, dynamic_import diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py index 910e9a39f..ae516fcb3 100644 --- a/netbox/virtualization/forms.py +++ b/netbox/virtualization/forms.py @@ -3,6 +3,7 @@ from django.core.exceptions import ValidationError from taggit.forms import TagField from dcim.choices import InterfaceModeChoices +from dcim.constants import INTERFACE_MTU_MAX, INTERFACE_MTU_MIN from dcim.forms import INTERFACE_MODE_HELP_TEXT from dcim.models import Device, DeviceRole, Interface, Platform, Rack, Region, Site from extras.forms import AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldForm, CustomFieldFilterForm @@ -745,8 +746,8 @@ class InterfaceCreateForm(ComponentForm): ) mtu = forms.IntegerField( required=False, - min_value=1, - max_value=32767, + min_value=INTERFACE_MTU_MIN, + max_value=INTERFACE_MTU_MAX, label='MTU' ) mac_address = forms.CharField( @@ -834,8 +835,8 @@ class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm): ) mtu = forms.IntegerField( required=False, - min_value=1, - max_value=32767, + min_value=INTERFACE_MTU_MIN, + max_value=INTERFACE_MTU_MAX, label='MTU' ) description = forms.CharField( @@ -931,8 +932,8 @@ class VirtualMachineBulkAddInterfaceForm(VirtualMachineBulkAddComponentForm): ) mtu = forms.IntegerField( required=False, - min_value=1, - max_value=32767, + min_value=INTERFACE_MTU_MIN, + max_value=INTERFACE_MTU_MAX, label='MTU' ) description = forms.CharField( From c6473d654d9995a45ebc4bf612a610d123a24a54 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 24 Jan 2020 15:03:38 -0500 Subject: [PATCH 25/28] Add explanatory text for constants --- netbox/dcim/constants.py | 4 +++- netbox/ipam/constants.py | 7 +++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/netbox/dcim/constants.py b/netbox/dcim/constants.py index 0301da7d8..0e05867e4 100644 --- a/netbox/dcim/constants.py +++ b/netbox/dcim/constants.py @@ -26,7 +26,7 @@ REARPORT_POSITIONS_MAX = 64 # INTERFACE_MTU_MIN = 1 -INTERFACE_MTU_MAX = 32767 +INTERFACE_MTU_MAX = 32767 # Max value of a signed 16-bit integer VIRTUAL_IFACE_TYPES = [ InterfaceTypeChoices.TYPE_VIRTUAL, @@ -49,7 +49,9 @@ NONCONNECTABLE_IFACE_TYPES = VIRTUAL_IFACE_TYPES + WIRELESS_IFACE_TYPES # POWERFEED_VOLTAGE_DEFAULT = 120 + POWERFEED_AMPERAGE_DEFAULT = 20 + POWERFEED_MAX_UTILIZATION_DEFAULT = 80 # Percentage diff --git a/netbox/ipam/constants.py b/netbox/ipam/constants.py index 22a977371..41075e54a 100644 --- a/netbox/ipam/constants.py +++ b/netbox/ipam/constants.py @@ -9,6 +9,11 @@ BGP_ASN_MAX = 2**32 - 1 # VRFs # +# Per RFC 4364 section 4.2, a route distinguisher may be encoded as one of the following: +# * Type 0 (16-bit AS number : 32-bit integer) +# * Type 1 (32-bit IPv4 address : 16-bit integer) +# * Type 2 (32-bit AS number : 16-bit integer) +# 21 characters are sufficient to convey the longest possible string value (255.255.255.255:65535) VRF_RD_MAX_LENGTH = 21 @@ -42,6 +47,7 @@ IPADDRESS_ROLES_NONUNIQUE = ( # VLANs # +# 12-bit VLAN ID (values 0 and 4095 are reserved) VLAN_VID_MIN = 1 VLAN_VID_MAX = 4094 @@ -50,5 +56,6 @@ VLAN_VID_MAX = 4094 # Services # +# 16-bit port number SERVICE_PORT_MIN = 1 SERVICE_PORT_MAX = 65535 From 35cbee5107f7d7fc2670b54286b1eb99e06b218c Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 24 Jan 2020 15:28:15 -0500 Subject: [PATCH 26/28] Fixes #4008: Toggle rack elevation face using front/rear strings --- docs/release-notes/version-2.7.md | 1 + netbox/dcim/views.py | 10 +++++----- netbox/templates/dcim/rack_elevation_list.html | 10 +++------- 3 files changed, 9 insertions(+), 12 deletions(-) diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index 112947751..5c489a96c 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -13,6 +13,7 @@ * [#3983](https://github.com/netbox-community/netbox/issues/3983) - Permit the creation of multiple unnamed devices * [#3989](https://github.com/netbox-community/netbox/issues/3989) - Correct HTTP content type assignment for webhooks * [#3999](https://github.com/netbox-community/netbox/issues/3999) - Do not filter child results by null if non-required parent fields are blank +* [#4008](https://github.com/netbox-community/netbox/issues/4008) - Toggle rack elevation face using front/rear strings --- diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index c83cee8fb..fd3d09ab7 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -30,6 +30,7 @@ from utilities.views import ( ) from virtualization.models import VirtualMachine from . import filters, forms, tables +from .choices import DeviceFaceChoices from .models import ( Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate, @@ -376,16 +377,15 @@ class RackElevationListView(PermissionRequiredMixin, View): page = paginator.page(paginator.num_pages) # Determine rack face - if request.GET.get('face') == '1': - face_id = 1 - else: - face_id = 0 + rack_face = request.GET.get('face', DeviceFaceChoices.FACE_FRONT) + if rack_face not in DeviceFaceChoices.values(): + rack_face = DeviceFaceChoices.FACE_FRONT return render(request, 'dcim/rack_elevation_list.html', { 'paginator': paginator, 'page': page, 'total_count': total_count, - 'face_id': face_id, + 'rack_face': rack_face, 'filter_form': forms.RackElevationFilterForm(request.GET), }) diff --git a/netbox/templates/dcim/rack_elevation_list.html b/netbox/templates/dcim/rack_elevation_list.html index de7c55919..da4f002d6 100644 --- a/netbox/templates/dcim/rack_elevation_list.html +++ b/netbox/templates/dcim/rack_elevation_list.html @@ -3,8 +3,8 @@ {% block content %}
- Front - Rear + Front + Rear

{% block title %}Rack Elevations{% endblock %}

@@ -17,11 +17,7 @@ {{ rack.name|truncatechars:"25" }}

{{ rack.facility_id|truncatechars:"30" }}

- {% if face_id %} - {% include 'dcim/inc/rack_elevation.html' with face='rear' %} - {% else %} - {% include 'dcim/inc/rack_elevation.html' with face='front' %} - {% endif %} + {% include 'dcim/inc/rack_elevation.html' with face=rack_face %}
{{ rack.name|truncatechars:"25" }} From 66330418cb1f516954bb763be821473c24c5a9e5 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 24 Jan 2020 15:40:03 -0500 Subject: [PATCH 27/28] Remove obsolete IP_FAMILY_CHOICES constant --- netbox/ipam/forms.py | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index d175fee4a..f1e55ef25 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -17,11 +17,6 @@ from .constants import * from .choices import * from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF -IP_FAMILY_CHOICES = [ - ('', 'All'), - (4, 'IPv4'), - (6, 'IPv6'), -] PREFIX_MASK_LENGTH_CHOICES = add_blank_choice([(i, i) for i in range(1, 128)]) IPADDRESS_MASK_LENGTH_CHOICES = add_blank_choice([(i, i) for i in range(1, 129)]) @@ -219,7 +214,7 @@ class AggregateFilterForm(BootstrapMixin, CustomFieldFilterForm): ) family = forms.ChoiceField( required=False, - choices=IP_FAMILY_CHOICES, + choices=add_blank_choice(IPAddressFamilyChoices), label='Address family', widget=StaticSelect2() ) @@ -511,7 +506,7 @@ class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm) ) family = forms.ChoiceField( required=False, - choices=IP_FAMILY_CHOICES, + choices=add_blank_choice(IPAddressFamilyChoices), label='Address family', widget=StaticSelect2() ) @@ -970,7 +965,7 @@ class IPAddressFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterFo ) family = forms.ChoiceField( required=False, - choices=IP_FAMILY_CHOICES, + choices=add_blank_choice(IPAddressFamilyChoices), label='Address family', widget=StaticSelect2() ) From a13bddde581657d18a2b6fad88b29deea221dd41 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 24 Jan 2020 15:50:45 -0500 Subject: [PATCH 28/28] Refactor prefix and IP mask length choice generation to reference constants --- netbox/ipam/forms.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index f1e55ef25..35cf12dfb 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -18,8 +18,13 @@ from .choices import * from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF -PREFIX_MASK_LENGTH_CHOICES = add_blank_choice([(i, i) for i in range(1, 128)]) -IPADDRESS_MASK_LENGTH_CHOICES = add_blank_choice([(i, i) for i in range(1, 129)]) +PREFIX_MASK_LENGTH_CHOICES = add_blank_choice([ + (i, i) for i in range(PREFIX_LENGTH_MIN, PREFIX_LENGTH_MAX + 1) +]) + +IPADDRESS_MASK_LENGTH_CHOICES = add_blank_choice([ + (i, i) for i in range(IPADDRESS_MASK_LENGTH_MIN, IPADDRESS_MASK_LENGTH_MAX + 1) +]) #