diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 8222d3d26..2a6aaf451 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -2952,9 +2952,10 @@ class FrontPortCreateForm(ComponentForm): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - parent = Device.objects.get(pk=self.initial['device']) + parent = Device.objects.get(pk=self.initial.get('device')) - # Determine which rear port positions are occupied. These will be excluded from the list of available mappings. + # Determine which rear port positions are occupied. These will be excluded from the list of available + # mappings. occupied_port_positions = [ (front_port.rear_port_id, front_port.rear_port_position) for front_port in parent.frontports.all() diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 328e0e603..2485dedee 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -682,10 +682,11 @@ class ConsolePortTestCase(StandardTestCases.Views): # Disable inapplicable views test_get_object = None + test_create_object = None test_bulk_edit_objects = None - # TODO - test_create_object = None + def test_bulk_create_objects(self): + return self._test_bulk_create_objects(expected_count=3) @classmethod def setUpTestData(cls): @@ -717,6 +718,14 @@ class ConsolePortTestCase(StandardTestCases.Views): "Device 1,Console Port 6", ) + cls.bulk_create_data = { + 'device': device.pk, + 'name_pattern': 'Console Port [4-6]', + 'type': ConsolePortTypeChoices.TYPE_RJ45, + 'description': 'A console port', + 'tags': 'Alpha,Bravo,Charlie', + } + class ConsoleServerPortTestCase(StandardTestCases.Views): model = ConsoleServerPort @@ -727,6 +736,9 @@ class ConsoleServerPortTestCase(StandardTestCases.Views): # TODO test_create_object = None + def test_bulk_create_objects(self): + return self._test_bulk_create_objects(expected_count=3) + @classmethod def setUpTestData(cls): device = create_test_device('Device 1') @@ -756,6 +768,14 @@ class ConsoleServerPortTestCase(StandardTestCases.Views): "Device 1,Console Server Port 6", ) + cls.bulk_create_data = { + 'device': device.pk, + 'name_pattern': 'Console Server Port [4-6]', + 'type': ConsolePortTypeChoices.TYPE_RJ45, + 'description': 'A console server port', + 'tags': 'Alpha,Bravo,Charlie', + } + cls.bulk_edit_data = { 'device': device.pk, 'type': ConsolePortTypeChoices.TYPE_RJ45, @@ -773,6 +793,9 @@ class PowerPortTestCase(StandardTestCases.Views): # TODO test_create_object = None + def test_bulk_create_objects(self): + return self._test_bulk_create_objects(expected_count=3) + @classmethod def setUpTestData(cls): device = create_test_device('Device 1') @@ -804,6 +827,16 @@ class PowerPortTestCase(StandardTestCases.Views): "Device 1,Power Port 6", ) + cls.bulk_create_data = { + 'device': device.pk, + 'name_pattern': 'Power Port [4-6]]', + 'type': PowerPortTypeChoices.TYPE_IEC_C14, + 'maximum_draw': 100, + 'allocated_draw': 50, + 'description': 'A power port', + 'tags': 'Alpha,Bravo,Charlie', + } + class PowerOutletTestCase(StandardTestCases.Views): model = PowerOutlet @@ -814,6 +847,9 @@ class PowerOutletTestCase(StandardTestCases.Views): # TODO test_create_object = None + def test_bulk_create_objects(self): + return self._test_bulk_create_objects(expected_count=3) + @classmethod def setUpTestData(cls): device = create_test_device('Device 1') @@ -851,6 +887,16 @@ class PowerOutletTestCase(StandardTestCases.Views): "Device 1,Power Outlet 6", ) + cls.bulk_create_data = { + 'device': device.pk, + 'name_pattern': 'Power Outlet [4-6]', + 'type': PowerOutletTypeChoices.TYPE_IEC_C13, + 'power_port': powerports[1].pk, + 'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_B, + 'description': 'A power outlet', + 'tags': 'Alpha,Bravo,Charlie', + } + cls.bulk_edit_data = { 'device': device.pk, 'type': PowerOutletTypeChoices.TYPE_IEC_C13, @@ -866,6 +912,9 @@ class InterfaceTestCase(StandardTestCases.Views): # TODO test_create_object = None + def test_bulk_create_objects(self): + return self._test_bulk_create_objects(expected_count=3) + @classmethod def setUpTestData(cls): device = create_test_device('Device 1') @@ -914,6 +963,22 @@ class InterfaceTestCase(StandardTestCases.Views): "Device 1,Interface 6,1000BASE-T (1GE)", ) + cls.bulk_create_data = { + 'device': device.pk, + 'name_pattern': 'Interface [4-6]', + 'type': InterfaceTypeChoices.TYPE_1GE_GBIC, + 'enabled': False, + 'lag': interfaces[3].pk, + 'mac_address': EUI('01:02:03:04:05:06'), + 'mtu': 2000, + 'mgmt_only': True, + 'description': 'A front port', + 'mode': InterfaceModeChoices.MODE_TAGGED, + 'untagged_vlan': vlans[0].pk, + 'tagged_vlans': [v.pk for v in vlans[1:4]], + 'tags': 'Alpha,Bravo,Charlie', + } + cls.bulk_edit_data = { 'device': device.pk, 'type': InterfaceTypeChoices.TYPE_1GE_GBIC, @@ -938,6 +1003,9 @@ class FrontPortTestCase(StandardTestCases.Views): # TODO test_create_object = None + def test_bulk_create_objects(self): + return self._test_bulk_create_objects(expected_count=3) + @classmethod def setUpTestData(cls): device = create_test_device('Device 1') @@ -978,6 +1046,17 @@ class FrontPortTestCase(StandardTestCases.Views): "Device 1,Front Port 6,8P8C,Rear Port 6,1", ) + cls.bulk_create_data = { + 'device': device.pk, + 'name_pattern': 'Front Port [4-6]', + 'type': PortTypeChoices.TYPE_8P8C, + 'rear_port_set': [ + '{}:1'.format(rp.pk) for rp in rearports[3:6] + ], + 'description': 'New description', + 'tags': 'Alpha,Bravo,Charlie', + } + cls.bulk_edit_data = { 'type': PortTypeChoices.TYPE_8P8C, 'description': 'New description', @@ -993,6 +1072,9 @@ class RearPortTestCase(StandardTestCases.Views): # TODO test_create_object = None + def test_bulk_create_objects(self): + return self._test_bulk_create_objects(expected_count=3) + @classmethod def setUpTestData(cls): device = create_test_device('Device 1') @@ -1022,6 +1104,15 @@ class RearPortTestCase(StandardTestCases.Views): "Device 1,Rear Port 6,8P8C,1", ) + cls.bulk_create_data = { + 'device': device.pk, + 'name_pattern': 'Rear Port [4-6]', + 'type': PortTypeChoices.TYPE_8P8C, + 'positions': 3, + 'description': 'A rear port', + 'tags': 'Alpha,Bravo,Charlie', + } + cls.bulk_edit_data = { 'type': PortTypeChoices.TYPE_8P8C, 'description': 'New description', @@ -1033,11 +1124,14 @@ class DeviceBayTestCase(StandardTestCases.Views): # Disable inapplicable views test_get_object = None + test_create_object = None # TODO - test_create_object = None test_bulk_edit_objects = None + def test_bulk_create_objects(self): + return self._test_bulk_create_objects(expected_count=3) + @classmethod def setUpTestData(cls): device1 = create_test_device('Device 1') @@ -1069,6 +1163,13 @@ class DeviceBayTestCase(StandardTestCases.Views): "Device 1,Device Bay 6", ) + cls.bulk_create_data = { + 'device': device2.pk, + 'name_pattern': 'Device Bay [4-6]', + 'description': 'A device bay', + 'tags': 'Alpha,Bravo,Charlie', + } + class InventoryItemTestCase(StandardTestCases.Views): model = InventoryItem diff --git a/netbox/utilities/testing/testcases.py b/netbox/utilities/testing/testcases.py index 7f9d6259e..44386a0be 100644 --- a/netbox/utilities/testing/testcases.py +++ b/netbox/utilities/testing/testcases.py @@ -1,5 +1,6 @@ from django.contrib.auth.models import Permission, User from django.core.exceptions import ObjectDoesNotExist +from django.forms.models import model_to_dict as model_to_dict_ from django.test import Client, TestCase as _TestCase, override_settings from django.urls import reverse, NoReverseMatch from rest_framework.test import APIClient @@ -56,6 +57,30 @@ class TestCase(_TestCase): expected_status, response.status_code, getattr(response, 'data', 'No data') )) + def assertInstanceEquals(self, instance, data): + """ + Compare a model instance to a dictionary, checking that its attribute values match those specified + in the dictionary. + """ + model_dict = model_to_dict_(instance, fields=data.keys()) + + for key in list(model_dict.keys()): + + # TODO: Differentiate between tags assigned to the instance and a M2M field for tags (ex: ConfigContext) + if key == 'tags': + model_dict[key] = ','.join(sorted([tag.name for tag in model_dict['tags']])) + + # Convert ManyToManyField to list of instance PKs + elif model_dict[key] and type(model_dict[key]) in (list, tuple) and hasattr(model_dict[key][0], 'pk'): + model_dict[key] = [obj.pk for obj in model_dict[key]] + + # Omit any dictionary keys which are not instance attributes + relevant_data = { + k: v for k, v in data.items() if hasattr(instance, k) + } + + self.assertDictEqual(model_dict, relevant_data) + class APITestCase(TestCase): client_class = APIClient @@ -92,6 +117,9 @@ class StandardTestCases: # CSV lines used for bulk import of new objects csv_data = () + # Form data used when creating multiple objects + bulk_create_data = {} + # Form data to be used when editing multiple objects at once bulk_edit_data = {} @@ -104,6 +132,10 @@ class StandardTestCases: if self.model is None: raise Exception("Test case requires model to be defined") + # + # URL functions + # + def _get_base_url(self): """ Return the base format for a URL for the test's model. Override this to test for a model which belongs @@ -138,6 +170,13 @@ class StandardTestCases: else: raise Exception("Invalid action for URL resolution: {}".format(action)) + # + # Standard view tests + # These methods will run by default. To disable a test, nullify its method on the subclasses TestCase: + # + # test_list_objects = None + # + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) def test_list_objects(self): # Attempt to make the request without required permissions @@ -331,3 +370,32 @@ class StandardTestCases: # Check that all objects were deleted self.assertEqual(self.model.objects.count(), 0) + + # + # Optional view tests + # These methods will run only if the required data + # + + @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) + def _test_bulk_create_objects(self, expected_count): + initial_count = self.model.objects.count() + request = { + 'path': self._get_url('add'), + 'data': post_data(self.bulk_create_data), + 'follow': False, # Do not follow 302 redirects + } + + # Attempt to make the request without required permissions + with disable_warnings('django.request'): + self.assertHttpStatus(self.client.post(**request), 403) + + # Assign the required permission and submit again + self.add_permissions( + '{}.add_{}'.format(self.model._meta.app_label, self.model._meta.model_name) + ) + response = self.client.post(**request) + self.assertHttpStatus(response, 302) + + self.assertEqual(initial_count + expected_count, self.model.objects.count()) + for instance in self.model.objects.order_by('-pk')[:expected_count]: + self.assertInstanceEquals(instance, self.bulk_create_data) diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py index 016672f04..718784d3e 100644 --- a/netbox/virtualization/forms.py +++ b/netbox/virtualization/forms.py @@ -812,7 +812,7 @@ class InterfaceCreateForm(ComponentForm): (group.name, [(vlan.pk, vlan) for vlan in global_group_vlans]) ) - parent = VirtualMachine.objects.get(pk=self.initial['virtual_machine']) + parent = VirtualMachine.objects.get(pk=self.initial.get('virtual_machine')) site = getattr(parent.cluster, 'site', None) if site is not None: diff --git a/netbox/virtualization/tests/test_views.py b/netbox/virtualization/tests/test_views.py index 35ab03269..7b570c3c7 100644 --- a/netbox/virtualization/tests/test_views.py +++ b/netbox/virtualization/tests/test_views.py @@ -198,10 +198,11 @@ class InterfaceTestCase(StandardTestCases.Views): # Disable inapplicable tests test_list_objects = None + test_create_object = None test_import_objects = None - # TODO - test_create_object = None + def test_bulk_create_objects(self): + return self._test_bulk_create_objects(expected_count=3) def _get_base_url(self): # Interface belongs to the DCIM app, so we have to override the base URL @@ -262,6 +263,21 @@ class InterfaceTestCase(StandardTestCases.Views): "Device 1,Interface 6,1000BASE-T (1GE)", ) + cls.bulk_create_data = { + 'virtual_machine': virtualmachines[1].pk, + 'name_pattern': 'Interface [4-6]', + 'type': InterfaceTypeChoices.TYPE_VIRTUAL, + 'enabled': False, + 'mgmt_only': False, + 'mac_address': EUI('01-02-03-04-05-06'), + 'mtu': 2000, + 'description': 'New description', + 'mode': InterfaceModeChoices.MODE_TAGGED, + 'untagged_vlan': vlans[0].pk, + 'tagged_vlans': [v.pk for v in vlans[1:4]], + 'tags': 'Alpha,Bravo,Charlie', + } + cls.bulk_edit_data = { 'virtual_machine': virtualmachines[1].pk, 'enabled': False,