diff --git a/CHANGELOG.md b/CHANGELOG.md index 36cb0d4f340..57f17a367e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,21 @@ +v2.4.6 (2018-10-05) + +## 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 + +* [#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 +* [#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 + +--- + v2.4.5 (2018-10-02) ## Enhancements diff --git a/docs/api/authentication.md b/docs/api/authentication.md index cb6da3bd13e..fa769c08e9e 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/circuits/tests/test_api.py b/netbox/circuits/tests/test_api.py index a67dbc4ab48..e6c98068fdc 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 a95743fd559..d0634e040dd 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 9edfe7bb9ca..ceec6747ddf 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/models.py b/netbox/dcim/models.py index 700741a1585..d8c39283877 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 " diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index d4a42c19624..c227179f4d1 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 0ff87d5cfc1..67b7e123ef0 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/ipam/views.py b/netbox/ipam/views.py index 91c74178998..2e3e0105c04 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' diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index c1440b85aff..cc393b8332a 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.5' +VERSION = '2.4.6' BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) diff --git a/netbox/project-static/js/forms.js b/netbox/project-static/js/forms.js index 193e95ae802..6cb621071ed 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&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/secrets/tests/test_api.py b/netbox/secrets/tests/test_api.py index 985e0ea7f99..d8d156ef311 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/templates/dcim/device_lldp_neighbors.html b/netbox/templates/dcim/device_lldp_neighbors.html index c0c82f459af..d4fbcbc79a2 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); diff --git a/netbox/templates/users/api_tokens.html b/netbox/templates/users/api_tokens.html index c0e5c55f939..b152497f5bf 100644 --- a/netbox/templates/users/api_tokens.html +++ b/netbox/templates/users/api_tokens.html @@ -10,8 +10,12 @@
You do not have any API tokens.
{% endfor %} - - - Add a token - + {% if perms.users.add_token %} + + + Add a token + + {% else %} +