diff --git a/.gitignore b/.gitignore index 485b46d59..95e4ff702 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ /netbox/static /venv/ /*.sh +local_requirements.txt !upgrade.sh fabfile.py gunicorn.py diff --git a/docs/additional-features/custom-links.md b/docs/additional-features/custom-links.md index 7c96eba8b..56d67a7be 100644 --- a/docs/additional-features/custom-links.md +++ b/docs/additional-features/custom-links.md @@ -24,7 +24,7 @@ Only links which render with non-empty text are included on the page. You can em For example, if you only want to display a link for active devices, you could set the link text to ``` -{% if obj.status == 1 %}View NMS{% endif %} +{% if obj.status == 'active' %}View NMS{% endif %} ``` The link will not appear when viewing a device with any status other than "active." diff --git a/docs/api/examples.md b/docs/api/examples.md index 1906d0db9..f4348907f 100644 --- a/docs/api/examples.md +++ b/docs/api/examples.md @@ -145,3 +145,18 @@ $ curl -v -X DELETE -H "Authorization: Token d2f763479f703d80de0ec15254237bc651f ``` The response to a successful `DELETE` request will have code 204 (No Content); the body of the response will be empty. + + +## Bulk Object Creation + +The REST API supports the creation of multiple objects of the same type using a single `POST` request. For example, to create multiple devices: + +``` +curl -X POST -H "Authorization: Token " -H "Content-Type: application/json" -H "Accept: application/json; indent=4" http://localhost:8000/api/dcim/devices/ --data '[ +{"name": "device1", "device_type": 24, "device_role": 17, "site": 6}, +{"name": "device2", "device_type": 24, "device_role": 17, "site": 6}, +{"name": "device3", "device_type": 24, "device_role": 17, "site": 6}, +]' +``` + +Bulk creation is all-or-none: If any of the creations fails, the entire operation is rolled back. A successful response returns an HTTP code 201 and the body of the response will be a list/array of the objects created. \ No newline at end of file diff --git a/docs/configuration/optional-settings.md b/docs/configuration/optional-settings.md index 3c4392915..119e6abf7 100644 --- a/docs/configuration/optional-settings.md +++ b/docs/configuration/optional-settings.md @@ -13,6 +13,14 @@ ADMINS = [ --- +## ALLOWED_URL_SCHEMES + +Default: `('file', 'ftp', 'ftps', 'http', 'https', 'irc', 'mailto', 'sftp', 'ssh', 'tel', 'telnet', 'tftp', 'vnc', 'xmpp')` + +A list of permitted URL schemes referenced when rendering links within NetBox. Note that only the schemes specified in this list will be accepted: If adding your own, be sure to replicate the entire default list as well (excluding those schemes which are not desirable). + +--- + ## BANNER_TOP ## BANNER_BOTTOM diff --git a/docs/release-notes/version-2.8.md b/docs/release-notes/version-2.8.md index 5ca86217a..7bddcbd40 100644 --- a/docs/release-notes/version-2.8.md +++ b/docs/release-notes/version-2.8.md @@ -1,5 +1,30 @@ # NetBox v2.8 +## v2.8.6 (2020-06-15) + +### Enhancements + +* [#4698](https://github.com/netbox-community/netbox/issues/4698) - Improve display of template code for object in admin UI +* [#4717](https://github.com/netbox-community/netbox/issues/4717) - Introduce `ALLOWED_URL_SCHEMES` configuration parameter to mitigate dangerous hyperlinks +* [#4744](https://github.com/netbox-community/netbox/issues/4744) - Hide "IP addresses" tab when viewing a container prefix +* [#4755](https://github.com/netbox-community/netbox/issues/4755) - Enable creation of rack reservations directly from navigation menu +* [#4761](https://github.com/netbox-community/netbox/issues/4761) - Enable tag assignment during bulk creation of IP addresses + +### Bug Fixes + +* [#4674](https://github.com/netbox-community/netbox/issues/4674) - Fix API definition for available prefix and IP address endpoints +* [#4702](https://github.com/netbox-community/netbox/issues/4702) - Catch IntegrityError exception when adding a non-unique secret +* [#4707](https://github.com/netbox-community/netbox/issues/4707) - Fix `prefix_count` population on VLAN API serializer +* [#4710](https://github.com/netbox-community/netbox/issues/4710) - Fix merging of form fields among custom scripts +* [#4725](https://github.com/netbox-community/netbox/issues/4725) - Fix "brief" rendering of various REST API endpoints +* [#4736](https://github.com/netbox-community/netbox/issues/4736) - Add cable trace endpoints for pass-through ports +* [#4737](https://github.com/netbox-community/netbox/issues/4737) - Fix display of role labels in virtual machines table +* [#4743](https://github.com/netbox-community/netbox/issues/4743) - Allow users to create "next available" IPs without needing permission to create prefixes +* [#4756](https://github.com/netbox-community/netbox/issues/4756) - Filter parent group by site when creating rack groups +* [#4760](https://github.com/netbox-community/netbox/issues/4760) - Enable power port template assignment when bulk editing power outlet templates + +--- + ## v2.8.5 (2020-05-26) **Note:** The minimum required version of PostgreSQL is now 9.6. diff --git a/netbox/circuits/tests/test_api.py b/netbox/circuits/tests/test_api.py index b5f8758e7..71db7234d 100644 --- a/netbox/circuits/tests/test_api.py +++ b/netbox/circuits/tests/test_api.py @@ -1,443 +1,188 @@ from django.contrib.contenttypes.models import ContentType from django.urls import reverse -from rest_framework import status from circuits.choices import * from circuits.models import Circuit, CircuitTermination, CircuitType, Provider from dcim.models import Site from extras.models import Graph -from utilities.testing import APITestCase +from utilities.testing import APITestCase, APIViewTestCases class AppTest(APITestCase): def test_root(self): - url = reverse('circuits-api:api-root') response = self.client.get('{}?format=api'.format(url), **self.header) self.assertEqual(response.status_code, 200) -class ProviderTest(APITestCase): +class ProviderTest(APIViewTestCases.APIViewTestCase): + model = Provider + brief_fields = ['circuit_count', 'id', 'name', 'slug', 'url'] + create_data = [ + { + 'name': 'Provider 4', + 'slug': 'provider-4', + }, + { + 'name': 'Provider 5', + 'slug': 'provider-5', + }, + { + 'name': 'Provider 6', + 'slug': 'provider-6', + }, + ] - def setUp(self): + @classmethod + def setUpTestData(cls): - super().setUp() - - self.provider1 = Provider.objects.create(name='Test Provider 1', slug='test-provider-1') - self.provider2 = Provider.objects.create(name='Test Provider 2', slug='test-provider-2') - self.provider3 = Provider.objects.create(name='Test Provider 3', slug='test-provider-3') - - def test_get_provider(self): - - url = reverse('circuits-api:provider-detail', kwargs={'pk': self.provider1.pk}) - response = self.client.get(url, **self.header) - - self.assertEqual(response.data['name'], self.provider1.name) + providers = ( + Provider(name='Provider 1', slug='provider-1'), + Provider(name='Provider 2', slug='provider-2'), + Provider(name='Provider 3', slug='provider-3'), + ) + Provider.objects.bulk_create(providers) def test_get_provider_graphs(self): + """ + Test retrieval of Graphs assigned to Providers. + """ + provider = self.model.objects.first() + ct = ContentType.objects.get(app_label='circuits', model='provider') + graphs = ( + Graph(type=ct, name='Graph 1', source='http://example.com/graphs.py?provider={{ obj.slug }}&foo=1'), + Graph(type=ct, name='Graph 2', source='http://example.com/graphs.py?provider={{ obj.slug }}&foo=2'), + Graph(type=ct, name='Graph 3', source='http://example.com/graphs.py?provider={{ obj.slug }}&foo=3'), + ) + Graph.objects.bulk_create(graphs) - provider_ct = ContentType.objects.get(app_label='circuits', model='provider') - self.graph1 = Graph.objects.create( - type=provider_ct, - name='Test Graph 1', - source='http://example.com/graphs.py?provider={{ obj.slug }}&foo=1' - ) - self.graph2 = Graph.objects.create( - type=provider_ct, - name='Test Graph 2', - source='http://example.com/graphs.py?provider={{ obj.slug }}&foo=2' - ) - self.graph3 = Graph.objects.create( - type=provider_ct, - name='Test Graph 3', - source='http://example.com/graphs.py?provider={{ obj.slug }}&foo=3' - ) - - url = reverse('circuits-api:provider-graphs', kwargs={'pk': self.provider1.pk}) + url = reverse('circuits-api:provider-graphs', kwargs={'pk': provider.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?provider=test-provider-1&foo=1') + self.assertEqual(response.data[0]['embed_url'], 'http://example.com/graphs.py?provider=provider-1&foo=1') - def test_list_providers(self): - url = reverse('circuits-api:provider-list') - response = self.client.get(url, **self.header) +class CircuitTypeTest(APIViewTestCases.APIViewTestCase): + model = CircuitType + brief_fields = ['circuit_count', 'id', 'name', 'slug', 'url'] + create_data = ( + { + 'name': 'Circuit Type 4', + 'slug': 'circuit-type-4', + }, + { + 'name': 'Circuit Type 5', + 'slug': 'circuit-type-5', + }, + { + 'name': 'Circuit Type 6', + 'slug': 'circuit-type-6', + }, + ) - self.assertEqual(response.data['count'], 3) + @classmethod + def setUpTestData(cls): - 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]), - ['circuit_count', 'id', 'name', 'slug', 'url'] + circuit_types = ( + CircuitType(name='Circuit Type 1', slug='circuit-type-1'), + CircuitType(name='Circuit Type 2', slug='circuit-type-2'), + CircuitType(name='Circuit Type 3', slug='circuit-type-3'), ) + CircuitType.objects.bulk_create(circuit_types) - def test_create_provider(self): - data = { - 'name': 'Test Provider 4', - 'slug': 'test-provider-4', - } +class CircuitTest(APIViewTestCases.APIViewTestCase): + model = Circuit + brief_fields = ['cid', 'id', 'url'] - url = reverse('circuits-api:provider-list') - response = self.client.post(url, data, format='json', **self.header) + @classmethod + def setUpTestData(cls): - self.assertHttpStatus(response, status.HTTP_201_CREATED) - self.assertEqual(Provider.objects.count(), 4) - provider4 = Provider.objects.get(pk=response.data['id']) - self.assertEqual(provider4.name, data['name']) - self.assertEqual(provider4.slug, data['slug']) + providers = ( + Provider(name='Provider 1', slug='provider-1'), + Provider(name='Provider 2', slug='provider-2'), + ) + Provider.objects.bulk_create(providers) - def test_create_provider_bulk(self): + circuit_types = ( + CircuitType(name='Circuit Type 1', slug='circuit-type-1'), + CircuitType(name='Circuit Type 2', slug='circuit-type-2'), + ) + CircuitType.objects.bulk_create(circuit_types) - data = [ + circuits = ( + Circuit(cid='Circuit 1', provider=providers[0], type=circuit_types[0]), + Circuit(cid='Circuit 2', provider=providers[0], type=circuit_types[0]), + Circuit(cid='Circuit 3', provider=providers[0], type=circuit_types[0]), + ) + Circuit.objects.bulk_create(circuits) + + cls.create_data = [ { - 'name': 'Test Provider 4', - 'slug': 'test-provider-4', + 'cid': 'Circuit 4', + 'provider': providers[1].pk, + 'type': circuit_types[1].pk, }, { - 'name': 'Test Provider 5', - 'slug': 'test-provider-5', + 'cid': 'Circuit 5', + 'provider': providers[1].pk, + 'type': circuit_types[1].pk, }, { - 'name': 'Test Provider 6', - 'slug': 'test-provider-6', + 'cid': 'Circuit 6', + 'provider': providers[1].pk, + 'type': circuit_types[1].pk, }, ] - url = reverse('circuits-api:provider-list') - response = self.client.post(url, data, format='json', **self.header) - self.assertHttpStatus(response, status.HTTP_201_CREATED) - self.assertEqual(Provider.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']) +class CircuitTerminationTest(APIViewTestCases.APIViewTestCase): + model = CircuitTermination + brief_fields = ['circuit', 'id', 'term_side', 'url'] - def test_update_provider(self): + @classmethod + def setUpTestData(cls): + SIDE_A = CircuitTerminationSideChoices.SIDE_A + SIDE_Z = CircuitTerminationSideChoices.SIDE_Z - data = { - 'name': 'Test Provider X', - 'slug': 'test-provider-x', - } - - url = reverse('circuits-api:provider-detail', kwargs={'pk': self.provider1.pk}) - response = self.client.put(url, data, format='json', **self.header) - - self.assertHttpStatus(response, status.HTTP_200_OK) - self.assertEqual(Provider.objects.count(), 3) - provider1 = Provider.objects.get(pk=response.data['id']) - self.assertEqual(provider1.name, data['name']) - self.assertEqual(provider1.slug, data['slug']) - - def test_delete_provider(self): - - url = reverse('circuits-api:provider-detail', kwargs={'pk': self.provider1.pk}) - response = self.client.delete(url, **self.header) - - self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) - self.assertEqual(Provider.objects.count(), 2) - - -class CircuitTypeTest(APITestCase): - - def setUp(self): - - super().setUp() - - self.circuittype1 = CircuitType.objects.create(name='Test Circuit Type 1', slug='test-circuit-type-1') - self.circuittype2 = CircuitType.objects.create(name='Test Circuit Type 2', slug='test-circuit-type-2') - self.circuittype3 = CircuitType.objects.create(name='Test Circuit Type 3', slug='test-circuit-type-3') - - def test_get_circuittype(self): - - url = reverse('circuits-api:circuittype-detail', kwargs={'pk': self.circuittype1.pk}) - response = self.client.get(url, **self.header) - - self.assertEqual(response.data['name'], self.circuittype1.name) - - def test_list_circuittypes(self): - - url = reverse('circuits-api:circuittype-list') - response = self.client.get(url, **self.header) - - 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]), - ['circuit_count', 'id', 'name', 'slug', 'url'] + sites = ( + Site(name='Site 1', slug='site-1'), + Site(name='Site 2', slug='site-2'), ) + Site.objects.bulk_create(sites) - def test_create_circuittype(self): + provider = Provider.objects.create(name='Provider 1', slug='provider-1') + circuit_type = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1') - data = { - 'name': 'Test Circuit Type 4', - 'slug': 'test-circuit-type-4', - } - - url = reverse('circuits-api:circuittype-list') - response = self.client.post(url, data, format='json', **self.header) - - self.assertHttpStatus(response, status.HTTP_201_CREATED) - self.assertEqual(CircuitType.objects.count(), 4) - circuittype4 = CircuitType.objects.get(pk=response.data['id']) - self.assertEqual(circuittype4.name, data['name']) - self.assertEqual(circuittype4.slug, data['slug']) - - def test_update_circuittype(self): - - data = { - 'name': 'Test Circuit Type X', - 'slug': 'test-circuit-type-x', - } - - url = reverse('circuits-api:circuittype-detail', kwargs={'pk': self.circuittype1.pk}) - response = self.client.put(url, data, format='json', **self.header) - - self.assertHttpStatus(response, status.HTTP_200_OK) - self.assertEqual(CircuitType.objects.count(), 3) - circuittype1 = CircuitType.objects.get(pk=response.data['id']) - self.assertEqual(circuittype1.name, data['name']) - self.assertEqual(circuittype1.slug, data['slug']) - - def test_delete_circuittype(self): - - url = reverse('circuits-api:circuittype-detail', kwargs={'pk': self.circuittype1.pk}) - response = self.client.delete(url, **self.header) - - self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) - self.assertEqual(CircuitType.objects.count(), 2) - - -class CircuitTest(APITestCase): - - def setUp(self): - - super().setUp() - - self.provider1 = Provider.objects.create(name='Test Provider 1', slug='test-provider-1') - self.provider2 = Provider.objects.create(name='Test Provider 2', slug='test-provider-2') - self.circuittype1 = CircuitType.objects.create(name='Test Circuit Type 1', slug='test-circuit-type-1') - self.circuittype2 = CircuitType.objects.create(name='Test Circuit Type 2', slug='test-circuit-type-2') - self.circuit1 = Circuit.objects.create(cid='TEST0001', provider=self.provider1, type=self.circuittype1) - self.circuit2 = Circuit.objects.create(cid='TEST0002', provider=self.provider1, type=self.circuittype1) - self.circuit3 = Circuit.objects.create(cid='TEST0003', provider=self.provider1, type=self.circuittype1) - - def test_get_circuit(self): - - url = reverse('circuits-api:circuit-detail', kwargs={'pk': self.circuit1.pk}) - response = self.client.get(url, **self.header) - - self.assertEqual(response.data['cid'], self.circuit1.cid) - - def test_list_circuits(self): - - url = reverse('circuits-api:circuit-list') - response = self.client.get(url, **self.header) - - 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'] + circuits = ( + Circuit(cid='Circuit 1', provider=provider, type=circuit_type), + Circuit(cid='Circuit 2', provider=provider, type=circuit_type), + Circuit(cid='Circuit 3', provider=provider, type=circuit_type), ) + Circuit.objects.bulk_create(circuits) - def test_create_circuit(self): + circuit_terminations = ( + CircuitTermination(circuit=circuits[0], site=sites[0], port_speed=100000, term_side=SIDE_A), + CircuitTermination(circuit=circuits[0], site=sites[1], port_speed=100000, term_side=SIDE_Z), + CircuitTermination(circuit=circuits[1], site=sites[0], port_speed=100000, term_side=SIDE_A), + CircuitTermination(circuit=circuits[1], site=sites[1], port_speed=100000, term_side=SIDE_Z), + ) + CircuitTermination.objects.bulk_create(circuit_terminations) - data = { - 'cid': 'TEST0004', - 'provider': self.provider1.pk, - 'type': self.circuittype1.pk, - 'status': CircuitStatusChoices.STATUS_ACTIVE, - } - - url = reverse('circuits-api:circuit-list') - response = self.client.post(url, data, format='json', **self.header) - - self.assertHttpStatus(response, status.HTTP_201_CREATED) - self.assertEqual(Circuit.objects.count(), 4) - circuit4 = Circuit.objects.get(pk=response.data['id']) - self.assertEqual(circuit4.cid, data['cid']) - self.assertEqual(circuit4.provider_id, data['provider']) - self.assertEqual(circuit4.type_id, data['type']) - - def test_create_circuit_bulk(self): - - data = [ + cls.create_data = [ { - 'cid': 'TEST0004', - 'provider': self.provider1.pk, - 'type': self.circuittype1.pk, - 'status': CircuitStatusChoices.STATUS_ACTIVE, + 'circuit': circuits[2].pk, + 'term_side': SIDE_A, + 'site': sites[1].pk, + 'port_speed': 200000, }, { - 'cid': 'TEST0005', - 'provider': self.provider1.pk, - 'type': self.circuittype1.pk, - 'status': CircuitStatusChoices.STATUS_ACTIVE, - }, - { - 'cid': 'TEST0006', - 'provider': self.provider1.pk, - 'type': self.circuittype1.pk, - 'status': CircuitStatusChoices.STATUS_ACTIVE, + 'circuit': circuits[2].pk, + 'term_side': SIDE_Z, + 'site': sites[1].pk, + 'port_speed': 200000, }, ] - - url = reverse('circuits-api:circuit-list') - response = self.client.post(url, data, format='json', **self.header) - - self.assertHttpStatus(response, status.HTTP_201_CREATED) - self.assertEqual(Circuit.objects.count(), 6) - self.assertEqual(response.data[0]['cid'], data[0]['cid']) - self.assertEqual(response.data[1]['cid'], data[1]['cid']) - self.assertEqual(response.data[2]['cid'], data[2]['cid']) - - def test_update_circuit(self): - - data = { - 'cid': 'TEST000X', - 'provider': self.provider2.pk, - 'type': self.circuittype2.pk, - } - - url = reverse('circuits-api:circuit-detail', kwargs={'pk': self.circuit1.pk}) - response = self.client.put(url, data, format='json', **self.header) - - self.assertHttpStatus(response, status.HTTP_200_OK) - self.assertEqual(Circuit.objects.count(), 3) - circuit1 = Circuit.objects.get(pk=response.data['id']) - self.assertEqual(circuit1.cid, data['cid']) - self.assertEqual(circuit1.provider_id, data['provider']) - self.assertEqual(circuit1.type_id, data['type']) - - def test_delete_circuit(self): - - url = reverse('circuits-api:circuit-detail', kwargs={'pk': self.circuit1.pk}) - response = self.client.delete(url, **self.header) - - self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) - self.assertEqual(Circuit.objects.count(), 2) - - -class CircuitTerminationTest(APITestCase): - - def setUp(self): - - super().setUp() - - self.site1 = Site.objects.create(name='Test Site 1', slug='test-site-1') - self.site2 = Site.objects.create(name='Test Site 2', slug='test-site-2') - provider = Provider.objects.create(name='Test Provider', slug='test-provider') - circuittype = CircuitType.objects.create(name='Test Circuit Type', slug='test-circuit-type') - self.circuit1 = Circuit.objects.create(cid='TEST0001', provider=provider, type=circuittype) - self.circuit2 = Circuit.objects.create(cid='TEST0002', provider=provider, type=circuittype) - self.circuit3 = Circuit.objects.create(cid='TEST0003', provider=provider, type=circuittype) - self.circuittermination1 = CircuitTermination.objects.create( - circuit=self.circuit1, - term_side=CircuitTerminationSideChoices.SIDE_A, - site=self.site1, - port_speed=1000000 - ) - self.circuittermination2 = CircuitTermination.objects.create( - circuit=self.circuit1, - term_side=CircuitTerminationSideChoices.SIDE_Z, - site=self.site2, - port_speed=1000000 - ) - self.circuittermination3 = CircuitTermination.objects.create( - circuit=self.circuit2, - term_side=CircuitTerminationSideChoices.SIDE_A, - site=self.site1, - port_speed=1000000 - ) - self.circuittermination4 = CircuitTermination.objects.create( - circuit=self.circuit2, - term_side=CircuitTerminationSideChoices.SIDE_Z, - site=self.site2, - port_speed=1000000 - ) - - def test_get_circuittermination(self): - - url = reverse('circuits-api:circuittermination-detail', kwargs={'pk': self.circuittermination1.pk}) - response = self.client.get(url, **self.header) - - self.assertEqual(response.data['id'], self.circuittermination1.pk) - - def test_list_circuitterminations(self): - - url = reverse('circuits-api:circuittermination-list') - response = self.client.get(url, **self.header) - - self.assertEqual(response.data['count'], 4) - - def test_create_circuittermination(self): - - data = { - 'circuit': self.circuit3.pk, - 'term_side': CircuitTerminationSideChoices.SIDE_A, - 'site': self.site1.pk, - 'port_speed': 1000000, - } - - url = reverse('circuits-api:circuittermination-list') - response = self.client.post(url, data, format='json', **self.header) - - self.assertHttpStatus(response, status.HTTP_201_CREATED) - self.assertEqual(CircuitTermination.objects.count(), 5) - circuittermination4 = CircuitTermination.objects.get(pk=response.data['id']) - self.assertEqual(circuittermination4.circuit_id, data['circuit']) - self.assertEqual(circuittermination4.term_side, data['term_side']) - self.assertEqual(circuittermination4.site_id, data['site']) - self.assertEqual(circuittermination4.port_speed, data['port_speed']) - - def test_update_circuittermination(self): - - circuittermination5 = CircuitTermination.objects.create( - circuit=self.circuit3, - term_side=CircuitTerminationSideChoices.SIDE_A, - site=self.site1, - port_speed=1000000 - ) - - data = { - 'circuit': self.circuit3.pk, - 'term_side': CircuitTerminationSideChoices.SIDE_Z, - 'site': self.site2.pk, - 'port_speed': 1000000, - } - - url = reverse('circuits-api:circuittermination-detail', kwargs={'pk': circuittermination5.pk}) - response = self.client.put(url, data, format='json', **self.header) - - self.assertHttpStatus(response, status.HTTP_200_OK) - self.assertEqual(CircuitTermination.objects.count(), 5) - circuittermination1 = CircuitTermination.objects.get(pk=response.data['id']) - self.assertEqual(circuittermination1.term_side, data['term_side']) - self.assertEqual(circuittermination1.site_id, data['site']) - self.assertEqual(circuittermination1.port_speed, data['port_speed']) - - def test_delete_circuittermination(self): - - url = reverse('circuits-api:circuittermination-detail', kwargs={'pk': self.circuittermination1.pk}) - response = self.client.delete(url, **self.header) - - self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) - self.assertEqual(CircuitTermination.objects.count(), 3) diff --git a/netbox/dcim/api/nested_serializers.py b/netbox/dcim/api/nested_serializers.py index bb2d61faa..83fcd7a2a 100644 --- a/netbox/dcim/api/nested_serializers.py +++ b/netbox/dcim/api/nested_serializers.py @@ -1,32 +1,35 @@ from rest_framework import serializers from dcim.constants import CONNECTION_STATUS_CHOICES -from dcim.models import ( - Cable, ConsolePort, ConsoleServerPort, Device, DeviceBay, DeviceType, DeviceRole, FrontPort, FrontPortTemplate, - Interface, Manufacturer, Platform, PowerFeed, PowerOutlet, PowerPanel, PowerPort, PowerPortTemplate, Rack, - RackGroup, RackRole, RearPort, RearPortTemplate, Region, Site, VirtualChassis, -) +from dcim import models from utilities.api import ChoiceField, WritableNestedSerializer __all__ = [ 'NestedCableSerializer', 'NestedConsolePortSerializer', + 'NestedConsolePortTemplateSerializer', 'NestedConsoleServerPortSerializer', + 'NestedConsoleServerPortTemplateSerializer', 'NestedDeviceBaySerializer', + 'NestedDeviceBayTemplateSerializer', 'NestedDeviceRoleSerializer', 'NestedDeviceSerializer', 'NestedDeviceTypeSerializer', 'NestedFrontPortSerializer', 'NestedFrontPortTemplateSerializer', 'NestedInterfaceSerializer', + 'NestedInterfaceTemplateSerializer', + 'NestedInventoryItemSerializer', 'NestedManufacturerSerializer', 'NestedPlatformSerializer', 'NestedPowerFeedSerializer', 'NestedPowerOutletSerializer', + 'NestedPowerOutletTemplateSerializer', 'NestedPowerPanelSerializer', 'NestedPowerPortSerializer', 'NestedPowerPortTemplateSerializer', 'NestedRackGroupSerializer', + 'NestedRackReservationSerializer', 'NestedRackRoleSerializer', 'NestedRackSerializer', 'NestedRearPortSerializer', @@ -46,7 +49,7 @@ class NestedRegionSerializer(WritableNestedSerializer): site_count = serializers.IntegerField(read_only=True) class Meta: - model = Region + model = models.Region fields = ['id', 'url', 'name', 'slug', 'site_count'] @@ -54,7 +57,7 @@ class NestedSiteSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:site-detail') class Meta: - model = Site + model = models.Site fields = ['id', 'url', 'name', 'slug'] @@ -67,7 +70,7 @@ class NestedRackGroupSerializer(WritableNestedSerializer): rack_count = serializers.IntegerField(read_only=True) class Meta: - model = RackGroup + model = models.RackGroup fields = ['id', 'url', 'name', 'slug', 'rack_count'] @@ -76,7 +79,7 @@ class NestedRackRoleSerializer(WritableNestedSerializer): rack_count = serializers.IntegerField(read_only=True) class Meta: - model = RackRole + model = models.RackRole fields = ['id', 'url', 'name', 'slug', 'rack_count'] @@ -85,10 +88,22 @@ class NestedRackSerializer(WritableNestedSerializer): device_count = serializers.IntegerField(read_only=True) class Meta: - model = Rack + model = models.Rack fields = ['id', 'url', 'name', 'display_name', 'device_count'] +class NestedRackReservationSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackreservation-detail') + user = serializers.SerializerMethodField(read_only=True) + + class Meta: + model = models.RackReservation + fields = ['id', 'url', 'user', 'units'] + + def get_user(self, obj): + return obj.user.username + + # # Device types # @@ -98,7 +113,7 @@ class NestedManufacturerSerializer(WritableNestedSerializer): devicetype_count = serializers.IntegerField(read_only=True) class Meta: - model = Manufacturer + model = models.Manufacturer fields = ['id', 'url', 'name', 'slug', 'devicetype_count'] @@ -108,15 +123,47 @@ class NestedDeviceTypeSerializer(WritableNestedSerializer): device_count = serializers.IntegerField(read_only=True) class Meta: - model = DeviceType + model = models.DeviceType fields = ['id', 'url', 'manufacturer', 'model', 'slug', 'display_name', 'device_count'] +class NestedConsolePortTemplateSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleporttemplate-detail') + + class Meta: + model = models.ConsolePortTemplate + fields = ['id', 'url', 'name'] + + +class NestedConsoleServerPortTemplateSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverporttemplate-detail') + + class Meta: + model = models.ConsoleServerPortTemplate + fields = ['id', 'url', 'name'] + + class NestedPowerPortTemplateSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerporttemplate-detail') class Meta: - model = PowerPortTemplate + model = models.PowerPortTemplate + fields = ['id', 'url', 'name'] + + +class NestedPowerOutletTemplateSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlettemplate-detail') + + class Meta: + model = models.PowerOutletTemplate + fields = ['id', 'url', 'name'] + + +class NestedInterfaceTemplateSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interfacetemplate-detail') + + class Meta: + model = models.InterfaceTemplate fields = ['id', 'url', 'name'] @@ -124,7 +171,7 @@ class NestedRearPortTemplateSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearporttemplate-detail') class Meta: - model = RearPortTemplate + model = models.RearPortTemplate fields = ['id', 'url', 'name'] @@ -132,7 +179,15 @@ class NestedFrontPortTemplateSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontporttemplate-detail') class Meta: - model = FrontPortTemplate + model = models.FrontPortTemplate + fields = ['id', 'url', 'name'] + + +class NestedDeviceBayTemplateSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicebaytemplate-detail') + + class Meta: + model = models.DeviceBayTemplate fields = ['id', 'url', 'name'] @@ -146,7 +201,7 @@ class NestedDeviceRoleSerializer(WritableNestedSerializer): virtualmachine_count = serializers.IntegerField(read_only=True) class Meta: - model = DeviceRole + model = models.DeviceRole fields = ['id', 'url', 'name', 'slug', 'device_count', 'virtualmachine_count'] @@ -156,7 +211,7 @@ class NestedPlatformSerializer(WritableNestedSerializer): virtualmachine_count = serializers.IntegerField(read_only=True) class Meta: - model = Platform + model = models.Platform fields = ['id', 'url', 'name', 'slug', 'device_count', 'virtualmachine_count'] @@ -164,7 +219,7 @@ class NestedDeviceSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:device-detail') class Meta: - model = Device + model = models.Device fields = ['id', 'url', 'name', 'display_name'] @@ -174,7 +229,7 @@ class NestedConsoleServerPortSerializer(WritableNestedSerializer): connection_status = ChoiceField(choices=CONNECTION_STATUS_CHOICES, read_only=True) class Meta: - model = ConsoleServerPort + model = models.ConsoleServerPort fields = ['id', 'url', 'device', 'name', 'cable', 'connection_status'] @@ -184,7 +239,7 @@ class NestedConsolePortSerializer(WritableNestedSerializer): connection_status = ChoiceField(choices=CONNECTION_STATUS_CHOICES, read_only=True) class Meta: - model = ConsolePort + model = models.ConsolePort fields = ['id', 'url', 'device', 'name', 'cable', 'connection_status'] @@ -194,7 +249,7 @@ class NestedPowerOutletSerializer(WritableNestedSerializer): connection_status = ChoiceField(choices=CONNECTION_STATUS_CHOICES, read_only=True) class Meta: - model = PowerOutlet + model = models.PowerOutlet fields = ['id', 'url', 'device', 'name', 'cable', 'connection_status'] @@ -204,7 +259,7 @@ class NestedPowerPortSerializer(WritableNestedSerializer): connection_status = ChoiceField(choices=CONNECTION_STATUS_CHOICES, read_only=True) class Meta: - model = PowerPort + model = models.PowerPort fields = ['id', 'url', 'device', 'name', 'cable', 'connection_status'] @@ -214,7 +269,7 @@ class NestedInterfaceSerializer(WritableNestedSerializer): connection_status = ChoiceField(choices=CONNECTION_STATUS_CHOICES, read_only=True) class Meta: - model = Interface + model = models.Interface fields = ['id', 'url', 'device', 'name', 'cable', 'connection_status'] @@ -223,7 +278,7 @@ class NestedRearPortSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearport-detail') class Meta: - model = RearPort + model = models.RearPort fields = ['id', 'url', 'device', 'name', 'cable'] @@ -232,7 +287,7 @@ class NestedFrontPortSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontport-detail') class Meta: - model = FrontPort + model = models.FrontPort fields = ['id', 'url', 'device', 'name', 'cable'] @@ -241,7 +296,16 @@ class NestedDeviceBaySerializer(WritableNestedSerializer): device = NestedDeviceSerializer(read_only=True) class Meta: - model = DeviceBay + model = models.DeviceBay + fields = ['id', 'url', 'device', 'name'] + + +class NestedInventoryItemSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitem-detail') + device = NestedDeviceSerializer(read_only=True) + + class Meta: + model = models.InventoryItem fields = ['id', 'url', 'device', 'name'] @@ -253,7 +317,7 @@ class NestedCableSerializer(serializers.ModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:cable-detail') class Meta: - model = Cable + model = models.Cable fields = ['id', 'url', 'label'] @@ -267,7 +331,7 @@ class NestedVirtualChassisSerializer(WritableNestedSerializer): member_count = serializers.IntegerField(read_only=True) class Meta: - model = VirtualChassis + model = models.VirtualChassis fields = ['id', 'url', 'master', 'member_count'] @@ -280,7 +344,7 @@ class NestedPowerPanelSerializer(WritableNestedSerializer): powerfeed_count = serializers.IntegerField(read_only=True) class Meta: - model = PowerPanel + model = models.PowerPanel fields = ['id', 'url', 'name', 'powerfeed_count'] @@ -288,5 +352,5 @@ class NestedPowerFeedSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerfeed-detail') class Meta: - model = PowerFeed + model = models.PowerFeed fields = ['id', 'url', 'name'] diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 9c8fe12de..f70193903 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -502,13 +502,13 @@ class InterfaceViewSet(CableTraceMixin, ModelViewSet): return Response(serializer.data) -class FrontPortViewSet(ModelViewSet): +class FrontPortViewSet(CableTraceMixin, ModelViewSet): queryset = FrontPort.objects.prefetch_related('device__device_type__manufacturer', 'rear_port', 'cable', 'tags') serializer_class = serializers.FrontPortSerializer filterset_class = filters.FrontPortFilterSet -class RearPortViewSet(ModelViewSet): +class RearPortViewSet(CableTraceMixin, ModelViewSet): queryset = RearPort.objects.prefetch_related('device__device_type__manufacturer', 'cable', 'tags') serializer_class = serializers.RearPortSerializer filterset_class = filters.RearPortFilterSet diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 94cf51fcd..c779f7f98 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -21,10 +21,10 @@ from ipam.models import IPAddress, VLAN from tenancy.forms import TenancyFilterForm, TenancyForm from tenancy.models import Tenant, TenantGroup from utilities.forms import ( - APISelect, APISelectMultiple, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm, - BulkEditNullBooleanSelect, ColorSelect, CommentField, ConfirmationForm, CSVChoiceField, CSVModelChoiceField, - CSVModelForm, DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model, - JSONField, SelectWithPK, SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField, + APISelect, APISelectMultiple, add_blank_choice, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, + ColorSelect, CommentField, ConfirmationForm, CSVChoiceField, CSVModelChoiceField, CSVModelForm, + DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model, JSONField, + NumericArrayField, SelectWithPK, SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, ) from virtualization.models import Cluster, ClusterGroup, VirtualMachine @@ -363,7 +363,12 @@ class SiteFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): class RackGroupForm(BootstrapMixin, forms.ModelForm): site = DynamicModelChoiceField( - queryset=Site.objects.all() + queryset=Site.objects.all(), + widget=APISelect( + filter_for={ + 'parent': 'site_id', + } + ) ) parent = DynamicModelChoiceField( queryset=RackGroup.objects.all(), @@ -729,21 +734,32 @@ class RackElevationFilterForm(RackFilterForm): # class RackReservationForm(BootstrapMixin, TenancyForm, forms.ModelForm): - rack = forms.ModelChoiceField( - queryset=Rack.objects.all(), + site = DynamicModelChoiceField( + queryset=Site.objects.all(), required=False, - widget=forms.HiddenInput() - ) - # TODO: Change this to an API-backed form field. We can't do this currently because we want to retain - # the multi-line