From 99848aab6ac2dfb2834ac66afb0438d7ebb06e91 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 3 Oct 2018 16:17:17 -0400 Subject: [PATCH 01/21] Fixes #2483: Set max item count of API-populated form fields to MAX_PAGE_SIZE --- CHANGELOG.md | 8 ++++++++ netbox/project-static/js/forms.js | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 36cb0d4f3..36304742d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +v2.4.6 (FUTURE) + +## Bug Fixes + +[#2483](https://github.com/digitalocean/netbox/issues/2483) - Set max item count of API-populated form fields to MAX_PAGE_SIZE + +--- + v2.4.5 (2018-10-02) ## Enhancements diff --git a/netbox/project-static/js/forms.js b/netbox/project-static/js/forms.js index 193e95ae8..967ff58c9 100644 --- a/netbox/project-static/js/forms.js +++ b/netbox/project-static/js/forms.js @@ -82,7 +82,7 @@ $(document).ready(function() { } if ($(parent).val() || $(parent).attr('nullable') == 'true') { - var api_url = child_field.attr('api-url'); + var api_url = child_field.attr('api-url') + '&limit=0'; var disabled_indicator = child_field.attr('disabled-indicator'); var initial_value = child_field.attr('initial'); var display_field = child_field.attr('display-field') || 'name'; From 1b2e9a6d066e118850cc01309f1c36b54374b9b4 Mon Sep 17 00:00:00 2001 From: John Anderson Date: Wed, 3 Oct 2018 17:16:01 -0400 Subject: [PATCH 02/21] fixes #2484 - Local config context not available on the Virtual Machine Edit Form --- CHANGELOG.md | 3 ++- netbox/virtualization/forms.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 36304742d..d15dd9bbb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,8 @@ v2.4.6 (FUTURE) ## Bug Fixes -[#2483](https://github.com/digitalocean/netbox/issues/2483) - Set max item count of API-populated form fields to MAX_PAGE_SIZE +* [#2483](https://github.com/digitalocean/netbox/issues/2483) - Set max item count of API-populated form fields to MAX_PAGE_SIZE +* [#2484](https://github.com/digitalocean/netbox/issues/2484) - Local config context not available on the Virtual Machine Edit Form --- diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py index e94c2cdaa..8f973955c 100644 --- a/netbox/virtualization/forms.py +++ b/netbox/virtualization/forms.py @@ -253,7 +253,7 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldForm): model = VirtualMachine fields = [ 'name', 'status', 'cluster_group', 'cluster', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', - 'vcpus', 'memory', 'disk', 'comments', 'tags', + 'vcpus', 'memory', 'disk', 'comments', 'tags', 'local_context_data', ] help_texts = { 'local_context_data': "Local config context data overwrites all sources contexts in the final rendered config context", From db2721c5815a153a361611da2ddf1e0cd733727f Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 4 Oct 2018 13:43:44 -0400 Subject: [PATCH 03/21] Enable brief API output utilizing nested serializers --- netbox/project-static/js/forms.js | 2 +- netbox/utilities/api.py | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/netbox/project-static/js/forms.js b/netbox/project-static/js/forms.js index 967ff58c9..55ecea977 100644 --- a/netbox/project-static/js/forms.js +++ b/netbox/project-static/js/forms.js @@ -82,7 +82,7 @@ $(document).ready(function() { } if ($(parent).val() || $(parent).attr('nullable') == 'true') { - var api_url = child_field.attr('api-url') + '&limit=0'; + var api_url = child_field.attr('api-url') + '&limit=0&brief'; var disabled_indicator = child_field.attr('disabled-indicator'); var initial_value = child_field.attr('initial'); var display_field = child_field.attr('display-field') || 'name'; diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py index e3011caf4..56ad405d9 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -192,6 +192,19 @@ class ModelViewSet(_ModelViewSet): return super(ModelViewSet, self).get_serializer(*args, **kwargs) + def get_serializer_class(self): + + # If 'brief' has been passed as a query param, find and return the nested serializer for this model, if one + # exists + request = self.get_serializer_context()['request'] + if 'brief' in request.query_params: + serializer_class = get_serializer_for_model(self.queryset.model, prefix='Nested') + if serializer_class is not None: + return serializer_class + + # Fall back to the hard-coded serializer class + return self.serializer_class + class FieldChoicesViewSet(ViewSet): """ From bf47e7cae3c359f18b785bec4b28deda7dfa0c64 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 4 Oct 2018 14:50:57 -0400 Subject: [PATCH 04/21] #2487: Require the 'brief' parameter to evaluate True --- netbox/project-static/js/forms.js | 2 +- netbox/utilities/api.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/netbox/project-static/js/forms.js b/netbox/project-static/js/forms.js index 55ecea977..6cb621071 100644 --- a/netbox/project-static/js/forms.js +++ b/netbox/project-static/js/forms.js @@ -82,7 +82,7 @@ $(document).ready(function() { } if ($(parent).val() || $(parent).attr('nullable') == 'true') { - var api_url = child_field.attr('api-url') + '&limit=0&brief'; + var api_url = child_field.attr('api-url') + '&limit=0&brief=1'; var disabled_indicator = child_field.attr('disabled-indicator'); var initial_value = child_field.attr('initial'); var display_field = child_field.attr('display-field') || 'name'; diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py index 56ad405d9..9b9dabef5 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -197,7 +197,7 @@ class ModelViewSet(_ModelViewSet): # If 'brief' has been passed as a query param, find and return the nested serializer for this model, if one # exists request = self.get_serializer_context()['request'] - if 'brief' in request.query_params: + if request.query_params.get('brief', False): serializer_class = get_serializer_for_model(self.queryset.model, prefix='Nested') if serializer_class is not None: return serializer_class From 259da2d18a84e9612702a237a8b0ada53a685123 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 4 Oct 2018 16:20:01 -0400 Subject: [PATCH 05/21] #2487: Added API tests --- netbox/circuits/tests/test_api.py | 30 ++++ netbox/dcim/api/serializers.py | 21 ++- netbox/dcim/api/views.py | 5 + netbox/dcim/tests/test_api.py | 180 +++++++++++++++++++++++ netbox/ipam/tests/test_api.py | 82 ++++++++++- netbox/secrets/tests/test_api.py | 10 ++ netbox/tenancy/tests/test_api.py | 20 +++ netbox/virtualization/api/serializers.py | 3 +- netbox/virtualization/api/views.py | 12 ++ netbox/virtualization/tests/test_api.py | 50 +++++++ 10 files changed, 410 insertions(+), 3 deletions(-) diff --git a/netbox/circuits/tests/test_api.py b/netbox/circuits/tests/test_api.py index a67dbc4ab..e6c98068f 100644 --- a/netbox/circuits/tests/test_api.py +++ b/netbox/circuits/tests/test_api.py @@ -56,6 +56,16 @@ class ProviderTest(APITestCase): self.assertEqual(response.data['count'], 3) + def test_list_providers_brief(self): + + url = reverse('circuits-api:provider-list') + response = self.client.get('{}?brief=1'.format(url), **self.header) + + self.assertEqual( + sorted(response.data['results'][0]), + ['id', 'name', 'slug', 'url'] + ) + def test_create_provider(self): data = { @@ -147,6 +157,16 @@ class CircuitTypeTest(APITestCase): self.assertEqual(response.data['count'], 3) + def test_list_circuittypes_brief(self): + + url = reverse('circuits-api:circuittype-list') + response = self.client.get('{}?brief=1'.format(url), **self.header) + + self.assertEqual( + sorted(response.data['results'][0]), + ['id', 'name', 'slug', 'url'] + ) + def test_create_circuittype(self): data = { @@ -216,6 +236,16 @@ class CircuitTest(APITestCase): self.assertEqual(response.data['count'], 3) + def test_list_circuits_brief(self): + + url = reverse('circuits-api:circuit-list') + response = self.client.get('{}?brief=1'.format(url), **self.header) + + self.assertEqual( + sorted(response.data['results'][0]), + ['cid', 'id', 'url'] + ) + def test_create_circuit(self): data = { diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index a95743fd5..d0634e040 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -492,6 +492,15 @@ class ConsolePortSerializer(TaggitSerializer, ValidatedModelSerializer): fields = ['id', 'device', 'name', 'cs_port', 'connection_status', 'tags'] +class NestedConsolePortSerializer(TaggitSerializer, ValidatedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleport-detail') + device = NestedDeviceSerializer(read_only=True) + + class Meta: + model = ConsolePort + fields = ['id', 'url', 'device', 'name'] + + # # Power outlets # @@ -529,6 +538,15 @@ class PowerPortSerializer(TaggitSerializer, ValidatedModelSerializer): fields = ['id', 'device', 'name', 'power_outlet', 'connection_status', 'tags'] +class NestedPowerPortSerializer(TaggitSerializer, ValidatedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerport-detail') + device = NestedDeviceSerializer(read_only=True) + + class Meta: + model = PowerPort + fields = ['id', 'url', 'device', 'name'] + + # # Interfaces # @@ -652,10 +670,11 @@ class DeviceBaySerializer(TaggitSerializer, ValidatedModelSerializer): class NestedDeviceBaySerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicebay-detail') + device = NestedDeviceSerializer(read_only=True) class Meta: model = DeviceBay - fields = ['id', 'url', 'name'] + fields = ['id', 'url', 'device', 'name'] # diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 9edfe7bb9..ceec6747d 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -238,6 +238,11 @@ class DeviceViewSet(CustomFieldModelViewSet): """ if self.action == 'retrieve': return serializers.DeviceWithConfigContextSerializer + + request = self.get_serializer_context()['request'] + if request.query_params.get('brief', False): + return serializers.NestedDeviceSerializer + return serializers.DeviceSerializer @action(detail=True, url_path='napalm') diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index d4a42c196..c227179f4 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -44,6 +44,16 @@ class RegionTest(APITestCase): self.assertEqual(response.data['count'], 3) + def test_list_regions_brief(self): + + url = reverse('dcim-api:region-list') + response = self.client.get('{}?brief=1'.format(url), **self.header) + + self.assertEqual( + sorted(response.data['results'][0]), + ['id', 'name', 'slug', 'url'] + ) + def test_create_region(self): data = { @@ -158,6 +168,16 @@ class SiteTest(APITestCase): self.assertEqual(response.data['count'], 3) + def test_list_sites_brief(self): + + url = reverse('dcim-api:site-list') + response = self.client.get('{}?brief=1'.format(url), **self.header) + + self.assertEqual( + sorted(response.data['results'][0]), + ['id', 'name', 'slug', 'url'] + ) + def test_create_site(self): data = { @@ -262,6 +282,16 @@ class RackGroupTest(APITestCase): self.assertEqual(response.data['count'], 3) + def test_list_rackgroups_brief(self): + + url = reverse('dcim-api:rackgroup-list') + response = self.client.get('{}?brief=1'.format(url), **self.header) + + self.assertEqual( + sorted(response.data['results'][0]), + ['id', 'name', 'slug', 'url'] + ) + def test_create_rackgroup(self): data = { @@ -360,6 +390,16 @@ class RackRoleTest(APITestCase): self.assertEqual(response.data['count'], 3) + def test_list_rackroles_brief(self): + + url = reverse('dcim-api:rackrole-list') + response = self.client.get('{}?brief=1'.format(url), **self.header) + + self.assertEqual( + sorted(response.data['results'][0]), + ['id', 'name', 'slug', 'url'] + ) + def test_create_rackrole(self): data = { @@ -477,6 +517,16 @@ class RackTest(APITestCase): self.assertEqual(response.data['count'], 3) + def test_list_racks_brief(self): + + url = reverse('dcim-api:rack-list') + response = self.client.get('{}?brief=1'.format(url), **self.header) + + self.assertEqual( + sorted(response.data['results'][0]), + ['display_name', 'id', 'name', 'url'] + ) + def test_create_rack(self): data = { @@ -693,6 +743,16 @@ class ManufacturerTest(APITestCase): self.assertEqual(response.data['count'], 3) + def test_list_manufacturers_brief(self): + + url = reverse('dcim-api:manufacturer-list') + response = self.client.get('{}?brief=1'.format(url), **self.header) + + self.assertEqual( + sorted(response.data['results'][0]), + ['id', 'name', 'slug', 'url'] + ) + def test_create_manufacturer(self): data = { @@ -792,6 +852,16 @@ class DeviceTypeTest(APITestCase): self.assertEqual(response.data['count'], 3) + def test_list_devicetypes_brief(self): + + url = reverse('dcim-api:devicetype-list') + response = self.client.get('{}?brief=1'.format(url), **self.header) + + self.assertEqual( + sorted(response.data['results'][0]), + ['id', 'manufacturer', 'model', 'slug', 'url'] + ) + def test_create_devicetype(self): data = { @@ -1496,6 +1566,16 @@ class DeviceRoleTest(APITestCase): self.assertEqual(response.data['count'], 3) + def test_list_deviceroles_brief(self): + + url = reverse('dcim-api:devicerole-list') + response = self.client.get('{}?brief=1'.format(url), **self.header) + + self.assertEqual( + sorted(response.data['results'][0]), + ['id', 'name', 'slug', 'url'] + ) + def test_create_devicerole(self): data = { @@ -1594,6 +1674,16 @@ class PlatformTest(APITestCase): self.assertEqual(response.data['count'], 3) + def test_list_platforms_brief(self): + + url = reverse('dcim-api:platform-list') + response = self.client.get('{}?brief=1'.format(url), **self.header) + + self.assertEqual( + sorted(response.data['results'][0]), + ['id', 'name', 'slug', 'url'] + ) + def test_create_platform(self): data = { @@ -1722,6 +1812,16 @@ class DeviceTest(APITestCase): self.assertEqual(response.data['count'], 3) + def test_list_devices_brief(self): + + url = reverse('dcim-api:device-list') + response = self.client.get('{}?brief=1'.format(url), **self.header) + + self.assertEqual( + sorted(response.data['results'][0]), + ['display_name', 'id', 'name', 'url'] + ) + def test_create_device(self): data = { @@ -1848,6 +1948,16 @@ class ConsolePortTest(APITestCase): self.assertEqual(response.data['count'], 3) + def test_list_consoleports_brief(self): + + url = reverse('dcim-api:consoleport-list') + response = self.client.get('{}?brief=1'.format(url), **self.header) + + self.assertEqual( + sorted(response.data['results'][0]), + ['device', 'id', 'name', 'url'] + ) + def test_create_consoleport(self): data = { @@ -1953,6 +2063,16 @@ class ConsoleServerPortTest(APITestCase): self.assertEqual(response.data['count'], 3) + def test_list_consoleserverports_brief(self): + + url = reverse('dcim-api:consoleserverport-list') + response = self.client.get('{}?brief=1'.format(url), **self.header) + + self.assertEqual( + sorted(response.data['results'][0]), + ['device', 'id', 'name', 'url'] + ) + def test_create_consoleserverport(self): data = { @@ -2054,6 +2174,16 @@ class PowerPortTest(APITestCase): self.assertEqual(response.data['count'], 3) + def test_list_powerports_brief(self): + + url = reverse('dcim-api:powerport-list') + response = self.client.get('{}?brief=1'.format(url), **self.header) + + self.assertEqual( + sorted(response.data['results'][0]), + ['device', 'id', 'name', 'url'] + ) + def test_create_powerport(self): data = { @@ -2159,6 +2289,16 @@ class PowerOutletTest(APITestCase): self.assertEqual(response.data['count'], 3) + def test_list_poweroutlets_brief(self): + + url = reverse('dcim-api:poweroutlet-list') + response = self.client.get('{}?brief=1'.format(url), **self.header) + + self.assertEqual( + sorted(response.data['results'][0]), + ['device', 'id', 'name', 'url'] + ) + def test_create_poweroutlet(self): data = { @@ -2285,6 +2425,16 @@ class InterfaceTest(APITestCase): self.assertEqual(response.data['count'], 3) + def test_list_interfaces_brief(self): + + url = reverse('dcim-api:interface-list') + response = self.client.get('{}?brief=1'.format(url), **self.header) + + self.assertEqual( + sorted(response.data['results'][0]), + ['device', 'id', 'name', 'url'] + ) + def test_create_interface(self): data = { @@ -2456,6 +2606,16 @@ class DeviceBayTest(APITestCase): self.assertEqual(response.data['count'], 3) + def test_list_devicebays_brief(self): + + url = reverse('dcim-api:devicebay-list') + response = self.client.get('{}?brief=1'.format(url), **self.header) + + self.assertEqual( + sorted(response.data['results'][0]), + ['device', 'id', 'name', 'url'] + ) + def test_create_devicebay(self): data = { @@ -2778,6 +2938,16 @@ class InterfaceConnectionTest(APITestCase): self.assertEqual(response.data['count'], 3) + def test_list_interfaceconnections_brief(self): + + url = reverse('dcim-api:interfaceconnection-list') + response = self.client.get('{}?brief=1'.format(url), **self.header) + + self.assertEqual( + sorted(response.data['results'][0]), + ['connection_status', 'id', 'url'] + ) + def test_create_interfaceconnection(self): data = { @@ -2973,6 +3143,16 @@ class VirtualChassisTest(APITestCase): self.assertEqual(response.data['count'], 2) + def test_list_virtualchassis_brief(self): + + url = reverse('dcim-api:virtualchassis-list') + response = self.client.get('{}?brief=1'.format(url), **self.header) + + self.assertEqual( + sorted(response.data['results'][0]), + ['id', 'url'] + ) + def test_create_virtualchassis(self): data = { diff --git a/netbox/ipam/tests/test_api.py b/netbox/ipam/tests/test_api.py index 0ff87d5cf..032d96d09 100644 --- a/netbox/ipam/tests/test_api.py +++ b/netbox/ipam/tests/test_api.py @@ -34,6 +34,16 @@ class VRFTest(APITestCase): self.assertEqual(response.data['count'], 3) + def test_list_vrfs_brief(self): + + url = reverse('ipam-api:vrf-list') + response = self.client.get('{}?brief=1'.format(url), **self.header) + + self.assertEqual( + sorted(response.data['results'][0]), + ['id', 'name', 'rd', 'url'] + ) + def test_create_vrf(self): data = { @@ -125,6 +135,16 @@ class RIRTest(APITestCase): self.assertEqual(response.data['count'], 3) + def test_list_rirs_brief(self): + + url = reverse('ipam-api:rir-list') + response = self.client.get('{}?brief=1'.format(url), **self.header) + + self.assertEqual( + sorted(response.data['results'][0]), + ['id', 'name', 'slug', 'url'] + ) + def test_create_rir(self): data = { @@ -218,6 +238,16 @@ class AggregateTest(APITestCase): self.assertEqual(response.data['count'], 3) + def test_list_aggregates_brief(self): + + url = reverse('ipam-api:aggregate-list') + response = self.client.get('{}?brief=1'.format(url), **self.header) + + self.assertEqual( + sorted(response.data['results'][0]), + ['family', 'id', 'prefix','url'] + ) + def test_create_aggregate(self): data = { @@ -309,6 +339,16 @@ class RoleTest(APITestCase): self.assertEqual(response.data['count'], 3) + def test_list_roles_brief(self): + + url = reverse('ipam-api:role-list') + response = self.client.get('{}?brief=1'.format(url), **self.header) + + self.assertEqual( + sorted(response.data['results'][0]), + ['id', 'name', 'slug', 'url'] + ) + def test_create_role(self): data = { @@ -397,13 +437,23 @@ class PrefixTest(APITestCase): self.assertEqual(response.data['prefix'], str(self.prefix1.prefix)) - def test_list_prefixs(self): + def test_list_prefixes(self): url = reverse('ipam-api:prefix-list') response = self.client.get(url, **self.header) self.assertEqual(response.data['count'], 3) + def test_list_prefixes_brief(self): + + url = reverse('ipam-api:prefix-list') + response = self.client.get('{}?brief=1'.format(url), **self.header) + + self.assertEqual( + sorted(response.data['results'][0]), + ['family', 'id', 'prefix', 'url'] + ) + def test_create_prefix(self): data = { @@ -630,6 +680,16 @@ class IPAddressTest(APITestCase): self.assertEqual(response.data['count'], 3) + def test_list_ipaddresses_brief(self): + + url = reverse('ipam-api:ipaddress-list') + response = self.client.get('{}?brief=1'.format(url), **self.header) + + self.assertEqual( + sorted(response.data['results'][0]), + ['address', 'family', 'id', 'url'] + ) + def test_create_ipaddress(self): data = { @@ -718,6 +778,16 @@ class VLANGroupTest(APITestCase): self.assertEqual(response.data['count'], 3) + def test_list_vlangroups_brief(self): + + url = reverse('ipam-api:vlangroup-list') + response = self.client.get('{}?brief=1'.format(url), **self.header) + + self.assertEqual( + sorted(response.data['results'][0]), + ['id', 'name', 'slug', 'url'] + ) + def test_create_vlangroup(self): data = { @@ -809,6 +879,16 @@ class VLANTest(APITestCase): self.assertEqual(response.data['count'], 3) + def test_list_vlans_brief(self): + + url = reverse('ipam-api:vlan-list') + response = self.client.get('{}?brief=1'.format(url), **self.header) + + self.assertEqual( + sorted(response.data['results'][0]), + ['display_name', 'id', 'name', 'url', 'vid'] + ) + def test_create_vlan(self): data = { diff --git a/netbox/secrets/tests/test_api.py b/netbox/secrets/tests/test_api.py index 985e0ea7f..d8d156ef3 100644 --- a/netbox/secrets/tests/test_api.py +++ b/netbox/secrets/tests/test_api.py @@ -73,6 +73,16 @@ class SecretRoleTest(APITestCase): self.assertEqual(response.data['count'], 3) + def test_list_secretroles_brief(self): + + url = reverse('secrets-api:secretrole-list') + response = self.client.get('{}?brief=1'.format(url), **self.header) + + self.assertEqual( + sorted(response.data['results'][0]), + ['id', 'name', 'slug', 'url'] + ) + def test_create_secretrole(self): data = { diff --git a/netbox/tenancy/tests/test_api.py b/netbox/tenancy/tests/test_api.py index 95e1a6de3..78b907d20 100644 --- a/netbox/tenancy/tests/test_api.py +++ b/netbox/tenancy/tests/test_api.py @@ -31,6 +31,16 @@ class TenantGroupTest(APITestCase): self.assertEqual(response.data['count'], 3) + def test_list_tenantgroups_brief(self): + + url = reverse('tenancy-api:tenantgroup-list') + response = self.client.get('{}?brief=1'.format(url), **self.header) + + self.assertEqual( + sorted(response.data['results'][0]), + ['id', 'name', 'slug', 'url'] + ) + def test_create_tenantgroup(self): data = { @@ -124,6 +134,16 @@ class TenantTest(APITestCase): self.assertEqual(response.data['count'], 3) + def test_list_tenants_brief(self): + + url = reverse('tenancy-api:tenant-list') + response = self.client.get('{}?brief=1'.format(url), **self.header) + + self.assertEqual( + sorted(response.data['results'][0]), + ['id', 'name', 'slug', 'url'] + ) + def test_create_tenant(self): data = { diff --git a/netbox/virtualization/api/serializers.py b/netbox/virtualization/api/serializers.py index 80a2f756a..b749f1e5e 100644 --- a/netbox/virtualization/api/serializers.py +++ b/netbox/virtualization/api/serializers.py @@ -169,7 +169,8 @@ class InterfaceSerializer(TaggitSerializer, ValidatedModelSerializer): class NestedInterfaceSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:interface-detail') + virtual_machine = NestedVirtualMachineSerializer(read_only=True) class Meta: model = Interface - fields = ['id', 'url', 'name'] + fields = ['id', 'url', 'virtual_machine', 'name'] diff --git a/netbox/virtualization/api/views.py b/netbox/virtualization/api/views.py index ab8ce7fbc..c3d644b8f 100644 --- a/netbox/virtualization/api/views.py +++ b/netbox/virtualization/api/views.py @@ -56,6 +56,11 @@ class VirtualMachineViewSet(CustomFieldModelViewSet): """ if self.action == 'retrieve': return serializers.VirtualMachineWithConfigContextSerializer + + request = self.get_serializer_context()['request'] + if request.query_params.get('brief', False): + return serializers.NestedVirtualMachineSerializer + return serializers.VirtualMachineSerializer @@ -65,3 +70,10 @@ class InterfaceViewSet(ModelViewSet): ).select_related('virtual_machine').prefetch_related('tags') serializer_class = serializers.InterfaceSerializer filter_class = filters.InterfaceFilter + + def get_serializer_class(self): + request = self.get_serializer_context()['request'] + if request.query_params.get('brief', False): + # Override get_serializer_for_model(), which will return the DCIM NestedInterfaceSerializer + return serializers.NestedInterfaceSerializer + return serializers.InterfaceSerializer diff --git a/netbox/virtualization/tests/test_api.py b/netbox/virtualization/tests/test_api.py index c3eebf5ed..32f56b99b 100644 --- a/netbox/virtualization/tests/test_api.py +++ b/netbox/virtualization/tests/test_api.py @@ -35,6 +35,16 @@ class ClusterTypeTest(APITestCase): self.assertEqual(response.data['count'], 3) + def test_list_clustertypes_brief(self): + + url = reverse('virtualization-api:clustertype-list') + response = self.client.get('{}?brief=1'.format(url), **self.header) + + self.assertEqual( + sorted(response.data['results'][0]), + ['id', 'name', 'slug', 'url'] + ) + def test_create_clustertype(self): data = { @@ -126,6 +136,16 @@ class ClusterGroupTest(APITestCase): self.assertEqual(response.data['count'], 3) + def test_list_clustergroups_brief(self): + + url = reverse('virtualization-api:clustergroup-list') + response = self.client.get('{}?brief=1'.format(url), **self.header) + + self.assertEqual( + sorted(response.data['results'][0]), + ['id', 'name', 'slug', 'url'] + ) + def test_create_clustergroup(self): data = { @@ -220,6 +240,16 @@ class ClusterTest(APITestCase): self.assertEqual(response.data['count'], 3) + def test_list_clusters_brief(self): + + url = reverse('virtualization-api:cluster-list') + response = self.client.get('{}?brief=1'.format(url), **self.header) + + self.assertEqual( + sorted(response.data['results'][0]), + ['id', 'name', 'url'] + ) + def test_create_cluster(self): data = { @@ -324,6 +354,16 @@ class VirtualMachineTest(APITestCase): self.assertEqual(response.data['count'], 3) + def test_list_virtualmachines_brief(self): + + url = reverse('virtualization-api:virtualmachine-list') + response = self.client.get('{}?brief=1'.format(url), **self.header) + + self.assertEqual( + sorted(response.data['results'][0]), + ['id', 'name', 'url'] + ) + def test_create_virtualmachine(self): data = { @@ -447,6 +487,16 @@ class InterfaceTest(APITestCase): self.assertEqual(response.data['count'], 3) + def test_list_interfaces_brief(self): + + url = reverse('virtualization-api:interface-list') + response = self.client.get('{}?brief=1'.format(url), **self.header) + + self.assertEqual( + sorted(response.data['results'][0]), + ['id', 'name', 'url', 'virtual_machine'] + ) + def test_create_interface(self): data = { From 52f1b1c3bf94ae27541421a46c118845afd28101 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 4 Oct 2018 16:24:09 -0400 Subject: [PATCH 06/21] Changelog entry for #2487 --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d15dd9bbb..6db5c2308 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ v2.4.6 (FUTURE) +## Enhancements + +* [#2487](https://github.com/digitalocean/netbox/issues/2487) - Return abbreviated API output when passed `?brief=1` + ## Bug Fixes * [#2483](https://github.com/digitalocean/netbox/issues/2483) - Set max item count of API-populated form fields to MAX_PAGE_SIZE From 2fee977b4c141b39e5b58e6ade9fb676f1d12d46 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 5 Oct 2018 10:30:13 -0400 Subject: [PATCH 07/21] Fixes #2485: Fix cancel button when assigning a service to a device/VM --- CHANGELOG.md | 1 + netbox/ipam/views.py | 3 +++ 2 files changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6db5c2308..d637f88c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ v2.4.6 (FUTURE) * [#2483](https://github.com/digitalocean/netbox/issues/2483) - Set max item count of API-populated form fields to MAX_PAGE_SIZE * [#2484](https://github.com/digitalocean/netbox/issues/2484) - Local config context not available on the Virtual Machine Edit Form +* [#2485](https://github.com/digitalocean/netbox/issues/2485) - Fix cancel button when assigning a service to a device/VM --- diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 91c741789..2e3e0105c 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -991,6 +991,9 @@ class ServiceCreateView(PermissionRequiredMixin, ObjectEditView): obj.virtual_machine = get_object_or_404(VirtualMachine, pk=url_kwargs['virtualmachine']) return obj + def get_return_url(self, request, service): + return service.parent.get_absolute_url() + class ServiceEditView(ServiceCreateView): permission_required = 'ipam.change_service' From 5d10d8418e91907135f1c8ba5ef9104fd3ad5eb3 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 5 Oct 2018 11:06:59 -0400 Subject: [PATCH 08/21] Closes #2479: Add user permissions for creating/modifying API tokens --- CHANGELOG.md | 1 + docs/api/authentication.md | 3 +++ netbox/templates/users/api_tokens.html | 22 ++++++++++++++----- .../migrations/0003_token_permissions.py | 17 ++++++++++++++ netbox/users/models.py | 2 +- netbox/users/views.py | 11 +++++++--- 6 files changed, 46 insertions(+), 10 deletions(-) create mode 100644 netbox/users/migrations/0003_token_permissions.py diff --git a/CHANGELOG.md b/CHANGELOG.md index d637f88c8..972dc6fe2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ v2.4.6 (FUTURE) ## Enhancements +* [#2479](https://github.com/digitalocean/netbox/issues/2479) - Add user permissions for creating/modifying API tokens * [#2487](https://github.com/digitalocean/netbox/issues/2487) - Return abbreviated API output when passed `?brief=1` ## Bug Fixes diff --git a/docs/api/authentication.md b/docs/api/authentication.md index cb6da3bd1..fa769c08e 100644 --- a/docs/api/authentication.md +++ b/docs/api/authentication.md @@ -4,6 +4,9 @@ The NetBox API employs token-based authentication. For convenience, cookie authe A token is a unique identifier that identifies a user to the API. Each user in NetBox may have one or more tokens which he or she can use to authenticate to the API. To create a token, navigate to the API tokens page at `/user/api-tokens/`. +!!! note + The creation and modification of API tokens can be restricted per user by an administrator. If you don't see an option to create an API token, ask an administrator to grant you access. + Each token contains a 160-bit key represented as 40 hexadecimal characters. When creating a token, you'll typically leave the key field blank so that a random key will be automatically generated. However, NetBox allows you to specify a key in case you need to restore a previously deleted token to operation. By default, a token can be used for all operations available via the API. Deselecting the "write enabled" option will restrict API requests made with the token to read operations (e.g. GET) only. diff --git a/netbox/templates/users/api_tokens.html b/netbox/templates/users/api_tokens.html index c0e5c55f9..b152497f5 100644 --- a/netbox/templates/users/api_tokens.html +++ b/netbox/templates/users/api_tokens.html @@ -10,8 +10,12 @@
- Edit - Delete + {% if perms.users.change_token %} + Edit + {% endif %} + {% if perms.users.delete_token %} + Delete + {% endif %}
{{ token.key }} {% if token.is_expired %} @@ -49,10 +53,16 @@ {% empty %}

You do not have any API tokens.

{% endfor %} - - - Add a token - + {% if perms.users.add_token %} + + + Add a token + + {% else %} + + {% endif %}
{% endblock %} diff --git a/netbox/users/migrations/0003_token_permissions.py b/netbox/users/migrations/0003_token_permissions.py new file mode 100644 index 000000000..a8a1f2a6e --- /dev/null +++ b/netbox/users/migrations/0003_token_permissions.py @@ -0,0 +1,17 @@ +# Generated by Django 2.0.8 on 2018-10-05 14:32 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0001_api_tokens_squashed_0002_unicode_literals'), + ] + + operations = [ + migrations.AlterModelOptions( + name='token', + options={}, + ), + ] diff --git a/netbox/users/models.py b/netbox/users/models.py index b3698d925..15f4f46f4 100644 --- a/netbox/users/models.py +++ b/netbox/users/models.py @@ -43,7 +43,7 @@ class Token(models.Model): ) class Meta: - default_permissions = [] + pass def __str__(self): # Only display the last 24 bits of the token to avoid accidental exposure. diff --git a/netbox/users/views.py b/netbox/users/views.py index c87fa5c7a..bc8263202 100644 --- a/netbox/users/views.py +++ b/netbox/users/views.py @@ -3,8 +3,8 @@ from __future__ import unicode_literals from django.contrib import messages from django.contrib.auth import login as auth_login, logout as auth_logout, update_session_auth_hash from django.contrib.auth.decorators import login_required -from django.contrib.auth.mixins import LoginRequiredMixin -from django.http import HttpResponseRedirect +from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin +from django.http import HttpResponseForbidden, HttpResponseRedirect from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse from django.utils.decorators import method_decorator @@ -231,8 +231,12 @@ class TokenEditView(LoginRequiredMixin, View): def get(self, request, pk=None): if pk is not None: + if not request.user.has_perm('users.change_token'): + return HttpResponseForbidden() token = get_object_or_404(Token.objects.filter(user=request.user), pk=pk) else: + if not request.user.has_perm('users.add_token'): + return HttpResponseForbidden() token = Token(user=request.user) form = TokenForm(instance=token) @@ -274,7 +278,8 @@ class TokenEditView(LoginRequiredMixin, View): }) -class TokenDeleteView(LoginRequiredMixin, View): +class TokenDeleteView(PermissionRequiredMixin, View): + permission_required = 'users.delete_token' def get(self, request, pk): From 841db3b0c2b5aee4702b1ba67c1ee8ef5e061896 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 5 Oct 2018 12:22:46 -0400 Subject: [PATCH 09/21] Fixes #2491: Fix exception when importing devices with invalid device type --- CHANGELOG.md | 1 + netbox/dcim/models.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 972dc6fe2..b8e84f4d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ v2.4.6 (FUTURE) * [#2483](https://github.com/digitalocean/netbox/issues/2483) - Set max item count of API-populated form fields to MAX_PAGE_SIZE * [#2484](https://github.com/digitalocean/netbox/issues/2484) - Local config context not available on the Virtual Machine Edit Form * [#2485](https://github.com/digitalocean/netbox/issues/2485) - Fix cancel button when assigning a service to a device/VM +* [#2491](https://github.com/digitalocean/netbox/issues/2491) - Fix exception when importing devices with invalid device type --- diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 700741a15..d8c392838 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -1400,7 +1400,7 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): }) # Validate manufacturer/platform - if self.device_type and self.platform: + if hasattr(self, 'device_type') and self.platform: if self.platform.manufacturer and self.platform.manufacturer != self.device_type.manufacturer: raise ValidationError({ 'platform': "The assigned platform is limited to {} device types, but this device's type belongs " From 1daf7f8e2bca2b787b0411eaeba734007cdb220d Mon Sep 17 00:00:00 2001 From: Marc Heckmann Date: Fri, 5 Oct 2018 14:30:54 -0400 Subject: [PATCH 10/21] Sanitize hostname and port values returned through LLDP If hostname or port are null set to empty string (""). This avoids breaking the LLDP neighbors (NAPALM) view --- netbox/templates/dcim/device_lldp_neighbors.html | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/netbox/templates/dcim/device_lldp_neighbors.html b/netbox/templates/dcim/device_lldp_neighbors.html index c0c82f459..d4fbcbc79 100644 --- a/netbox/templates/dcim/device_lldp_neighbors.html +++ b/netbox/templates/dcim/device_lldp_neighbors.html @@ -64,8 +64,10 @@ $(document).ready(function() { } // Clean up hostnames/interfaces learned via LLDP - var lldp_device = neighbor['hostname'].split(".")[0]; // Strip off any trailing domain name - var lldp_interface = neighbor['port'].split(".")[0]; // Strip off any trailing subinterface ID + var neighbor_host = neighbor['hostname'] || ""; // sanitize hostname if it's null to avoid breaking the split func + var neighbor_port = neighbor['port'] || ""; // sanitize port if it's null to avoid breaking the split func + var lldp_device = neighbor_host.split(".")[0]; // Strip off any trailing domain name + var lldp_interface = neighbor_port.split(".")[0]; // Strip off any trailing subinterface ID // Add LLDP neighbors to table row.children('td.device').html(lldp_device); From 4c376287842240f86f2112e56eba93cfd6550024 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 5 Oct 2018 15:33:29 -0400 Subject: [PATCH 11/21] Fixes #2393: Fix Unicode support for CSV import under Python 2 --- CHANGELOG.md | 1 + netbox/utilities/forms.py | 13 ++++++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b8e84f4d2..a1a6d0dbb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ v2.4.6 (FUTURE) ## Bug Fixes +* [#2393](https://github.com/digitalocean/netbox/issues/2393) - Fix Unicode support for CSV import under Python 2 * [#2483](https://github.com/digitalocean/netbox/issues/2483) - Set max item count of API-populated form fields to MAX_PAGE_SIZE * [#2484](https://github.com/digitalocean/netbox/issues/2484) - Local config context not available on the Virtual Machine Edit Form * [#2485](https://github.com/digitalocean/netbox/issues/2485) - Fix cancel button when assigning a service to a device/VM diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index 47ba3744f..f54b418ca 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -4,6 +4,7 @@ import csv from io import StringIO import json import re +import sys from django import forms from django.conf import settings @@ -150,6 +151,11 @@ def add_blank_choice(choices): return ((None, '---------'),) + tuple(choices) +def utf8_encoder(data): + for line in data: + yield line.encode('utf-8') + + # # Widgets # @@ -303,7 +309,12 @@ class CSVDataField(forms.CharField): def to_python(self, value): records = [] - reader = csv.reader(StringIO(value)) + + # Python 2 hack for Unicode support in the CSV reader + if sys.version_info[0] < 3: + reader = csv.reader(utf8_encoder(StringIO(value))) + else: + reader = csv.reader(StringIO(value)) # Consume and validate the first line of CSV data as column headers headers = next(reader) From 470d22c8354e971c64e5a41af65314527ea149c0 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 5 Oct 2018 15:36:48 -0400 Subject: [PATCH 12/21] PEP8 fix --- netbox/ipam/tests/test_api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/ipam/tests/test_api.py b/netbox/ipam/tests/test_api.py index 032d96d09..67b7e123e 100644 --- a/netbox/ipam/tests/test_api.py +++ b/netbox/ipam/tests/test_api.py @@ -245,7 +245,7 @@ class AggregateTest(APITestCase): self.assertEqual( sorted(response.data['results'][0]), - ['family', 'id', 'prefix','url'] + ['family', 'id', 'prefix', 'url'] ) def test_create_aggregate(self): From 83f3dc99cefe63c3dbf8aa6a4ad431aca2056e52 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 5 Oct 2018 15:39:30 -0400 Subject: [PATCH 13/21] Changelog entry for #2492 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a1a6d0dbb..010dcefec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ v2.4.6 (FUTURE) * [#2484](https://github.com/digitalocean/netbox/issues/2484) - Local config context not available on the Virtual Machine Edit Form * [#2485](https://github.com/digitalocean/netbox/issues/2485) - Fix cancel button when assigning a service to a device/VM * [#2491](https://github.com/digitalocean/netbox/issues/2491) - Fix exception when importing devices with invalid device type +* [#2492](https://github.com/digitalocean/netbox/issues/2492) - Sanitize hostname and port values returned through LLDP --- From c2f4cf3407484152ffc3a643c85513082eba5af1 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 5 Oct 2018 15:43:43 -0400 Subject: [PATCH 14/21] Release v2.4.6 --- CHANGELOG.md | 2 +- netbox/netbox/settings.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 010dcefec..57f17a367 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -v2.4.6 (FUTURE) +v2.4.6 (2018-10-05) ## Enhancements diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 2c855fe31..cc393b833 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -22,7 +22,7 @@ if sys.version_info[0] < 3: DeprecationWarning ) -VERSION = '2.4.6-dev' +VERSION = '2.4.6' BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) From 7d1f6b7049a5eec95092b1c003a9ac8dfdd1d553 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 5 Oct 2018 15:49:51 -0400 Subject: [PATCH 15/21] Post-release version bump --- netbox/netbox/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index cc393b833..c96503e4d 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -22,7 +22,7 @@ if sys.version_info[0] < 3: DeprecationWarning ) -VERSION = '2.4.6' +VERSION = '2.4.7-dev' BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) From c31c7b50b748be5359eaf94a4cc4fc28b2917455 Mon Sep 17 00:00:00 2001 From: Tobias Genannt Date: Mon, 3 Sep 2018 08:11:35 +0200 Subject: [PATCH 16/21] Fix #2395: Modify only when webhooks are enabled This only adds the RQ link when the webhooks setting is enabled. --- netbox/netbox/admin.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/netbox/netbox/admin.py b/netbox/netbox/admin.py index 4c7e0f81b..34faba233 100644 --- a/netbox/netbox/admin.py +++ b/netbox/netbox/admin.py @@ -23,8 +23,9 @@ admin_site.register(User, UserAdmin) admin_site.register(Tag, TagAdmin) # Modify the template to include an RQ link if django_rq is installed (see RQ_SHOW_ADMIN_LINK) -try: - import django_rq - admin_site.index_template = 'django_rq/index.html' -except ImportError: - pass +if settings.WEBHOOKS_ENABLED: + try: + import django_rq + admin_site.index_template = 'django_rq/index.html' + except ImportError: + pass From 6832df4699413f321fa4127a49902d282313df83 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 10 Oct 2018 09:49:35 -0400 Subject: [PATCH 17/21] Fixes #2508: Removed invalid link --- docs/core-functionality/devices.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/core-functionality/devices.md b/docs/core-functionality/devices.md index 0d3df016c..06f78cabb 100644 --- a/docs/core-functionality/devices.md +++ b/docs/core-functionality/devices.md @@ -101,7 +101,7 @@ Device bays represent the ability of a device to house child devices. For exampl A platform defines the type of software running on a device or virtual machine. This can be helpful when it is necessary to distinguish between, for instance, different feature sets. Note that two devices of the same type may be assigned different platforms: for example, one Juniper MX240 running Junos 14 and another running Junos 15. -The platform model is also used to indicate which [NAPALM](https://napalm-automation.net/) driver NetBox should use when connecting to a remote device. The name of the driver along with optional parameters are stored with the platform. See the [API documentation](api/napalm-integration.md) for more information on NAPALM integration. +The platform model is also used to indicate which [NAPALM](https://napalm-automation.net/) driver NetBox should use when connecting to a remote device. The name of the driver along with optional parameters are stored with the platform. The assignment of platforms to devices is an optional feature, and may be disregarded if not desired. From df5d105f291182fb57acdd4b8e85de7b2b9cc3da Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 16 Oct 2018 09:42:19 -0400 Subject: [PATCH 18/21] Changelog for #2515 --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 57f17a367..61cff2c30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +v2.4.7 (FUTURE) + +## Bug Fixes + +* [#2515](https://github.com/digitalocean/netbox/issues/2515) - Only use django-rq admin tmeplate if webhooks are enabled + +--- + v2.4.6 (2018-10-05) ## Enhancements From 0ae2dfbff3908c77d9125bf9763dc591021dcf49 Mon Sep 17 00:00:00 2001 From: Chris James Date: Tue, 16 Oct 2018 11:36:32 -0500 Subject: [PATCH 19/21] Fix "cusomizable" typo --- docs/core-functionality/devices.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/core-functionality/devices.md b/docs/core-functionality/devices.md index 06f78cabb..5ae599c73 100644 --- a/docs/core-functionality/devices.md +++ b/docs/core-functionality/devices.md @@ -58,7 +58,7 @@ A device is said to be full depth if its installation on one rack face prevents ## Device Roles -Devices can be organized by functional roles. These roles are fully cusomizable. For example, you might create roles for core switches, distribution switches, and access switches. +Devices can be organized by functional roles. These roles are fully customizable. For example, you might create roles for core switches, distribution switches, and access switches. --- From 409a9256a188b6c679da7c4d5ccb1d1a042cfb6c Mon Sep 17 00:00:00 2001 From: mmahacek Date: Tue, 16 Oct 2018 10:19:33 -0700 Subject: [PATCH 20/21] Expand Webhook Documentation #2347 (#2524) * #2347 - Expand Webhook Documentation Move "Install Python Packages" section up one header level. Should make Napalm/Webhook sections appear in table of contents for direct linking. * #2347 - Expand Webhook Documentation Add text for installation to link to other documentation sections with instructions. --- docs/additional-features/webhooks.md | 8 ++++++++ docs/installation/2-netbox.md | 6 +++--- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/docs/additional-features/webhooks.md b/docs/additional-features/webhooks.md index 0e74640fa..68f342e88 100644 --- a/docs/additional-features/webhooks.md +++ b/docs/additional-features/webhooks.md @@ -4,6 +4,14 @@ A webhook defines an HTTP request that is sent to an external application when c An optional secret key can be configured for each webhook. This will append a `X-Hook-Signature` header to the request, consisting of a HMAC (SHA-512) hex digest of the request body using the secret as the key. This digest can be used by the receiver to authenticate the request's content. +## Installation + +If you are upgrading from a previous version of Netbox and want to enable the webhook feature, please follow the directions listed in the sections below. + +* [Install Redis server and djano-rq package](../installation/2-netbox/#install-python-packages) +* [Modify configuration to enable webhooks](../installation/2-netbox/#webhooks-configuration) +* [Create supervisord program to run the rqworker process](../installation/3-http-daemon/#supervisord-installation) + ## Requests The webhook POST request is structured as so (assuming `application/json` as the Content-Type): diff --git a/docs/installation/2-netbox.md b/docs/installation/2-netbox.md index 8f59adc29..ad4556383 100644 --- a/docs/installation/2-netbox.md +++ b/docs/installation/2-netbox.md @@ -71,7 +71,7 @@ Checking connectivity... done. `# chown -R netbox:netbox /opt/netbox/netbox/media/` -## Install Python Packages +# Install Python Packages Install the required Python packages using pip. (If you encounter any compilation errors during this step, ensure that you've installed all of the system dependencies listed above.) @@ -82,7 +82,7 @@ Install the required Python packages using pip. (If you encounter any compilatio !!! note If you encounter errors while installing the required packages, check that you're running a recent version of pip (v9.0.1 or higher) with the command `pip3 -V`. -### NAPALM Automation (Optional) +## NAPALM Automation (Optional) NetBox supports integration with the [NAPALM automation](https://napalm-automation.net/) library. NAPALM allows NetBox to fetch live data from devices and return it to a requester via its REST API. Installation of NAPALM is optional. To enable it, install the `napalm` package using pip or pip3: @@ -90,7 +90,7 @@ NetBox supports integration with the [NAPALM automation](https://napalm-automati # pip3 install napalm ``` -### Webhooks (Optional) +## Webhooks (Optional) [Webhooks](../data-model/extras/#webhooks) allow NetBox to integrate with external services by pushing out a notification each time a relevant object is created, updated, or deleted. Enabling the webhooks feature requires [Redis](https://redis.io/), a lightweight in-memory database. You may opt to install a Redis sevice locally (see below) or connect to an external one. From 0bb5d229e83def3d2c1c571f1cc5baaabc76c7e0 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 16 Oct 2018 16:41:12 -0400 Subject: [PATCH 21/21] Fixes #2514: Prevent new connections to already connected interfaces --- CHANGELOG.md | 1 + netbox/dcim/api/serializers.py | 59 ++++++++++++++++++++++------------ netbox/dcim/forms.py | 8 ++--- netbox/dcim/models.py | 57 +++++++++++++++++++++----------- netbox/dcim/tests/test_api.py | 10 +++--- 5 files changed, 87 insertions(+), 48 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 61cff2c30..eeb2749cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ v2.4.7 (FUTURE) ## Bug Fixes +* [#2514](https://github.com/digitalocean/netbox/issues/2514) - Prevent new connections to already connected interfaces * [#2515](https://github.com/digitalocean/netbox/issues/2515) - Only use django-rq admin tmeplate if webhooks are enabled --- diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index d0634e040..b0a1628de 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -472,10 +472,14 @@ class ConsoleServerPortSerializer(TaggitSerializer, ValidatedModelSerializer): class NestedConsoleServerPortSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverport-detail') device = NestedDeviceSerializer(read_only=True) + is_connected = serializers.SerializerMethodField(read_only=True) class Meta: model = ConsoleServerPort - fields = ['id', 'url', 'device', 'name'] + fields = ['id', 'url', 'device', 'name', 'is_connected'] + + def get_is_connected(self, obj): + return hasattr(obj, 'connected_console') and obj.connected_console is not None # @@ -495,10 +499,14 @@ class ConsolePortSerializer(TaggitSerializer, ValidatedModelSerializer): class NestedConsolePortSerializer(TaggitSerializer, ValidatedModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleport-detail') device = NestedDeviceSerializer(read_only=True) + is_connected = serializers.SerializerMethodField(read_only=True) class Meta: model = ConsolePort - fields = ['id', 'url', 'device', 'name'] + fields = ['id', 'url', 'device', 'name', 'is_connected'] + + def get_is_connected(self, obj): + return obj.cs_port is not None # @@ -518,10 +526,14 @@ class PowerOutletSerializer(TaggitSerializer, ValidatedModelSerializer): class NestedPowerOutletSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlet-detail') device = NestedDeviceSerializer(read_only=True) + is_connected = serializers.SerializerMethodField(read_only=True) class Meta: model = PowerOutlet - fields = ['id', 'url', 'device', 'name'] + fields = ['id', 'url', 'device', 'name', 'is_connected'] + + def get_is_connected(self, obj): + return hasattr(obj, 'connected_port') and obj.connected_port is not None # @@ -541,23 +553,43 @@ class PowerPortSerializer(TaggitSerializer, ValidatedModelSerializer): class NestedPowerPortSerializer(TaggitSerializer, ValidatedModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerport-detail') device = NestedDeviceSerializer(read_only=True) + is_connected = serializers.SerializerMethodField(read_only=True) class Meta: model = PowerPort - fields = ['id', 'url', 'device', 'name'] + fields = ['id', 'url', 'device', 'name', 'is_connected'] + + def get_is_connected(self, obj): + return obj.power_outlet is not None # # Interfaces # -class NestedInterfaceSerializer(WritableNestedSerializer): +class IsConnectedMixin(object): + """ + Provide a method for setting is_connected on Interface serializers. + """ + def get_is_connected(self, obj): + """ + Return True if the interface has a connected interface or circuit. + """ + if obj.connection: + return True + if hasattr(obj, 'circuit_termination') and obj.circuit_termination is not None: + return True + return False + + +class NestedInterfaceSerializer(IsConnectedMixin, WritableNestedSerializer): device = NestedDeviceSerializer(read_only=True) url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail') + is_connected = serializers.SerializerMethodField(read_only=True) class Meta: model = Interface - fields = ['id', 'url', 'device', 'name'] + fields = ['id', 'url', 'device', 'name', 'is_connected'] class InterfaceNestedCircuitSerializer(serializers.ModelSerializer): @@ -587,7 +619,7 @@ class InterfaceVLANSerializer(WritableNestedSerializer): fields = ['id', 'url', 'vid', 'name', 'display_name'] -class InterfaceSerializer(TaggitSerializer, ValidatedModelSerializer): +class InterfaceSerializer(TaggitSerializer, IsConnectedMixin, ValidatedModelSerializer): device = NestedDeviceSerializer() form_factor = ChoiceField(choices=IFACE_FF_CHOICES, required=False) lag = NestedInterfaceSerializer(required=False, allow_null=True) @@ -631,19 +663,6 @@ class InterfaceSerializer(TaggitSerializer, ValidatedModelSerializer): return super(InterfaceSerializer, self).validate(data) - def get_is_connected(self, obj): - """ - Return True if the interface has a connected interface or circuit termination. - """ - if obj.connection: - return True - try: - circuit_termination = obj.circuit_termination - return True - except CircuitTermination.DoesNotExist: - pass - return False - def get_interface_connection(self, obj): if obj.connection: context = { diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index e7fa15f7b..6b6ea1187 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -1328,7 +1328,7 @@ class ConsolePortConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelF label='Port', widget=APISelect( api_url='/api/dcim/console-server-ports/?device_id={{console_server}}', - disabled_indicator='connected_console', + disabled_indicator='is_connected', ) ) @@ -1419,7 +1419,7 @@ class ConsoleServerPortConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms. label='Port', widget=APISelect( api_url='/api/dcim/console-ports/?device_id={{device}}', - disabled_indicator='cs_port' + disabled_indicator='is_connected' ) ) connection_status = forms.BooleanField( @@ -1597,7 +1597,7 @@ class PowerPortConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelFor label='Outlet', widget=APISelect( api_url='/api/dcim/power-outlets/?device_id={{pdu}}', - disabled_indicator='connected_port' + disabled_indicator='is_connected' ) ) @@ -1688,7 +1688,7 @@ class PowerOutletConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.Form): label='Port', widget=APISelect( api_url='/api/dcim/power-ports/?device_id={{device}}', - disabled_indicator='power_outlet' + disabled_indicator='is_connected' ) ) connection_status = forms.BooleanField( diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index d8c392838..33885e203 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -2035,25 +2035,44 @@ class InterfaceConnection(models.Model): csv_headers = ['device_a', 'interface_a', 'device_b', 'interface_b', 'connection_status'] def clean(self): - try: - if self.interface_a == self.interface_b: - raise ValidationError({ - 'interface_b': "Cannot connect an interface to itself." - }) - if self.interface_a.form_factor in NONCONNECTABLE_IFACE_TYPES: - raise ValidationError({ - 'interface_a': '{} is not a connectable interface type.'.format( - self.interface_a.get_form_factor_display() - ) - }) - if self.interface_b.form_factor in NONCONNECTABLE_IFACE_TYPES: - raise ValidationError({ - 'interface_b': '{} is not a connectable interface type.'.format( - self.interface_b.get_form_factor_display() - ) - }) - except ObjectDoesNotExist: - pass + + # An interface cannot be connected to itself + if self.interface_a == self.interface_b: + raise ValidationError({ + 'interface_b': "Cannot connect an interface to itself." + }) + + # Only connectable interface types are permitted + if self.interface_a.form_factor in NONCONNECTABLE_IFACE_TYPES: + raise ValidationError({ + 'interface_a': '{} is not a connectable interface type.'.format( + self.interface_a.get_form_factor_display() + ) + }) + if self.interface_b.form_factor in NONCONNECTABLE_IFACE_TYPES: + raise ValidationError({ + 'interface_b': '{} is not a connectable interface type.'.format( + self.interface_b.get_form_factor_display() + ) + }) + + # Prevent the A side of one connection from being the B side of another + interface_a_connections = InterfaceConnection.objects.filter( + Q(interface_a=self.interface_a) | + Q(interface_b=self.interface_a) + ).exclude(pk=self.pk) + if interface_a_connections.exists(): + raise ValidationError({ + 'interface_a': "This interface is already connected." + }) + interface_b_connections = InterfaceConnection.objects.filter( + Q(interface_a=self.interface_b) | + Q(interface_b=self.interface_b) + ).exclude(pk=self.pk) + if interface_b_connections.exists(): + raise ValidationError({ + 'interface_b': "This interface is already connected." + }) def to_csv(self): return ( diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index c227179f4..04952a4d4 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -1955,7 +1955,7 @@ class ConsolePortTest(APITestCase): self.assertEqual( sorted(response.data['results'][0]), - ['device', 'id', 'name', 'url'] + ['device', 'id', 'is_connected', 'name', 'url'] ) def test_create_consoleport(self): @@ -2070,7 +2070,7 @@ class ConsoleServerPortTest(APITestCase): self.assertEqual( sorted(response.data['results'][0]), - ['device', 'id', 'name', 'url'] + ['device', 'id', 'is_connected', 'name', 'url'] ) def test_create_consoleserverport(self): @@ -2181,7 +2181,7 @@ class PowerPortTest(APITestCase): self.assertEqual( sorted(response.data['results'][0]), - ['device', 'id', 'name', 'url'] + ['device', 'id', 'is_connected', 'name', 'url'] ) def test_create_powerport(self): @@ -2296,7 +2296,7 @@ class PowerOutletTest(APITestCase): self.assertEqual( sorted(response.data['results'][0]), - ['device', 'id', 'name', 'url'] + ['device', 'id', 'is_connected', 'name', 'url'] ) def test_create_poweroutlet(self): @@ -2432,7 +2432,7 @@ class InterfaceTest(APITestCase): self.assertEqual( sorted(response.data['results'][0]), - ['device', 'id', 'name', 'url'] + ['device', 'id', 'is_connected', 'name', 'url'] ) def test_create_interface(self):