Jelajahi Sumber

Merge pull request #4098 from netbox-community/4086-device-component-urls

Closes #4086: Rename device component create/edit/delete URLs
Jeremy Stretch 6 tahun lalu
induk
melakukan
b1e78fa3c4

+ 64 - 25
netbox/dcim/forms.py

@@ -2180,6 +2180,10 @@ class ConsolePortForm(BootstrapMixin, forms.ModelForm):
 
 
 
 
 class ConsolePortCreateForm(ComponentForm):
 class ConsolePortCreateForm(ComponentForm):
+    device = forms.ModelChoiceField(
+        queryset=Device.objects.all(),
+        widget=forms.HiddenInput()
+    )
     name_pattern = ExpandableNameField(
     name_pattern = ExpandableNameField(
         label='Name'
         label='Name'
     )
     )
@@ -2238,6 +2242,10 @@ class ConsoleServerPortForm(BootstrapMixin, forms.ModelForm):
 
 
 
 
 class ConsoleServerPortCreateForm(ComponentForm):
 class ConsoleServerPortCreateForm(ComponentForm):
+    device = forms.ModelChoiceField(
+        queryset=Device.objects.all(),
+        widget=forms.HiddenInput()
+    )
     name_pattern = ExpandableNameField(
     name_pattern = ExpandableNameField(
         label='Name'
         label='Name'
     )
     )
@@ -2331,6 +2339,10 @@ class PowerPortForm(BootstrapMixin, forms.ModelForm):
 
 
 
 
 class PowerPortCreateForm(ComponentForm):
 class PowerPortCreateForm(ComponentForm):
+    device = forms.ModelChoiceField(
+        queryset=Device.objects.all(),
+        widget=forms.HiddenInput()
+    )
     name_pattern = ExpandableNameField(
     name_pattern = ExpandableNameField(
         label='Name'
         label='Name'
     )
     )
@@ -2412,6 +2424,10 @@ class PowerOutletForm(BootstrapMixin, forms.ModelForm):
 
 
 
 
 class PowerOutletCreateForm(ComponentForm):
 class PowerOutletCreateForm(ComponentForm):
+    device = forms.ModelChoiceField(
+        queryset=Device.objects.all(),
+        widget=forms.HiddenInput()
+    )
     name_pattern = ExpandableNameField(
     name_pattern = ExpandableNameField(
         label='Name'
         label='Name'
     )
     )
@@ -2437,11 +2453,13 @@ class PowerOutletCreateForm(ComponentForm):
     )
     )
 
 
     def __init__(self, *args, **kwargs):
     def __init__(self, *args, **kwargs):
-
         super().__init__(*args, **kwargs)
         super().__init__(*args, **kwargs)
 
 
-        # Limit power_port choices to those on the parent device
-        self.fields['power_port'].queryset = PowerPort.objects.filter(device=self.parent)
+        # Limit power_port queryset to PowerPorts which belong to the parent Device
+        device = Device.objects.get(
+            pk=self.initial.get('device') or self.data.get('device')
+        )
+        self.fields['power_port'].queryset = PowerPort.objects.filter(device=device)
 
 
 
 
 class PowerOutletCSVForm(forms.ModelForm):
 class PowerOutletCSVForm(forms.ModelForm):
@@ -2499,6 +2517,10 @@ class PowerOutletBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
         queryset=PowerOutlet.objects.all(),
         queryset=PowerOutlet.objects.all(),
         widget=forms.MultipleHiddenInput()
         widget=forms.MultipleHiddenInput()
     )
     )
+    device = forms.ModelChoiceField(
+        queryset=Device.objects.all(),
+        widget=forms.HiddenInput()
+    )
     type = forms.ChoiceField(
     type = forms.ChoiceField(
         choices=add_blank_choice(PowerOutletTypeChoices),
         choices=add_blank_choice(PowerOutletTypeChoices),
         required=False
         required=False
@@ -2525,7 +2547,9 @@ class PowerOutletBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
         super().__init__(*args, **kwargs)
         super().__init__(*args, **kwargs)
 
 
         # Limit power_port queryset to PowerPorts which belong to the parent Device
         # Limit power_port queryset to PowerPorts which belong to the parent Device
-        self.fields['power_port'].queryset = PowerPort.objects.filter(device=self.parent_obj)
+        if 'device' in self.initial:
+            device = Device.objects.filter(pk=self.initial['device']).first()
+            self.fields['power_port'].queryset = PowerPort.objects.filter(device=device)
 
 
 
 
 class PowerOutletBulkRenameForm(BulkRenameForm):
 class PowerOutletBulkRenameForm(BulkRenameForm):
@@ -2625,7 +2649,8 @@ class InterfaceCreateForm(InterfaceCommonForm, ComponentForm, forms.Form):
         widget=StaticSelect2(),
         widget=StaticSelect2(),
     )
     )
     enabled = forms.BooleanField(
     enabled = forms.BooleanField(
-        required=False
+        required=False,
+        initial=True
     )
     )
     lag = forms.ModelChoiceField(
     lag = forms.ModelChoiceField(
         queryset=Interface.objects.all(),
         queryset=Interface.objects.all(),
@@ -2680,21 +2705,16 @@ class InterfaceCreateForm(InterfaceCommonForm, ComponentForm, forms.Form):
     )
     )
 
 
     def __init__(self, *args, **kwargs):
     def __init__(self, *args, **kwargs):
-
-        # Set interfaces enabled by default
-        kwargs['initial'] = kwargs.get('initial', {}).copy()
-        kwargs['initial'].update({'enabled': True})
-
         super().__init__(*args, **kwargs)
         super().__init__(*args, **kwargs)
 
 
-        # Limit LAG choices to interfaces belonging to this device (or its VC master)
-        if self.parent is not None:
-            self.fields['lag'].queryset = Interface.objects.filter(
-                device__in=[self.parent, self.parent.get_vc_master()],
-                type=InterfaceTypeChoices.TYPE_LAG
-            )
-        else:
-            self.fields['lag'].queryset = Interface.objects.none()
+        # Limit LAG choices to interfaces which belong to the parent device (or VC master)
+        device = Device.objects.get(
+            pk=self.initial.get('device') or self.data.get('device')
+        )
+        self.fields['lag'].queryset = Interface.objects.filter(
+            device__in=[device, device.get_vc_master()],
+            type=InterfaceTypeChoices.TYPE_LAG
+        )
 
 
 
 
 class InterfaceCSVForm(forms.ModelForm):
 class InterfaceCSVForm(forms.ModelForm):
@@ -2769,6 +2789,10 @@ class InterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
         queryset=Interface.objects.all(),
         queryset=Interface.objects.all(),
         widget=forms.MultipleHiddenInput()
         widget=forms.MultipleHiddenInput()
     )
     )
+    device = forms.ModelChoiceField(
+        queryset=Device.objects.all(),
+        widget=forms.HiddenInput()
+    )
     type = forms.ChoiceField(
     type = forms.ChoiceField(
         choices=add_blank_choice(InterfaceTypeChoices),
         choices=add_blank_choice(InterfaceTypeChoices),
         required=False,
         required=False,
@@ -2836,14 +2860,12 @@ class InterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
         super().__init__(*args, **kwargs)
         super().__init__(*args, **kwargs)
 
 
         # Limit LAG choices to interfaces which belong to the parent device (or VC master)
         # Limit LAG choices to interfaces which belong to the parent device (or VC master)
-        device = self.parent_obj
-        if device is not None:
+        if 'device' in self.initial:
+            device = Device.objects.filter(pk=self.initial['device']).first()
             self.fields['lag'].queryset = Interface.objects.filter(
             self.fields['lag'].queryset = Interface.objects.filter(
                 device__in=[device, device.get_vc_master()],
                 device__in=[device, device.get_vc_master()],
                 type=InterfaceTypeChoices.TYPE_LAG
                 type=InterfaceTypeChoices.TYPE_LAG
             )
             )
-        else:
-            self.fields['lag'].choices = []
 
 
     def clean(self):
     def clean(self):
 
 
@@ -2909,6 +2931,10 @@ class FrontPortForm(BootstrapMixin, forms.ModelForm):
 
 
 # TODO: Merge with FrontPortTemplateCreateForm to remove duplicate logic
 # TODO: Merge with FrontPortTemplateCreateForm to remove duplicate logic
 class FrontPortCreateForm(ComponentForm):
 class FrontPortCreateForm(ComponentForm):
+    device = forms.ModelChoiceField(
+        queryset=Device.objects.all(),
+        widget=forms.HiddenInput()
+    )
     name_pattern = ExpandableNameField(
     name_pattern = ExpandableNameField(
         label='Name'
         label='Name'
     )
     )
@@ -2928,15 +2954,20 @@ class FrontPortCreateForm(ComponentForm):
     def __init__(self, *args, **kwargs):
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
         super().__init__(*args, **kwargs)
 
 
-        # Determine which rear port positions are occupied. These will be excluded from the list of available mappings.
+        device = Device.objects.get(
+            pk=self.initial.get('device') or self.data.get('device')
+        )
+
+        # Determine which rear port positions are occupied. These will be excluded from the list of available
+        # mappings.
         occupied_port_positions = [
         occupied_port_positions = [
             (front_port.rear_port_id, front_port.rear_port_position)
             (front_port.rear_port_id, front_port.rear_port_position)
-            for front_port in self.parent.frontports.all()
+            for front_port in device.frontports.all()
         ]
         ]
 
 
         # Populate rear port choices
         # Populate rear port choices
         choices = []
         choices = []
-        rear_ports = RearPort.objects.filter(device=self.parent)
+        rear_ports = RearPort.objects.filter(device=device)
         for rear_port in rear_ports:
         for rear_port in rear_ports:
             for i in range(1, rear_port.positions + 1):
             for i in range(1, rear_port.positions + 1):
                 if (rear_port.pk, i) not in occupied_port_positions:
                 if (rear_port.pk, i) not in occupied_port_positions:
@@ -3076,6 +3107,10 @@ class RearPortForm(BootstrapMixin, forms.ModelForm):
 
 
 
 
 class RearPortCreateForm(ComponentForm):
 class RearPortCreateForm(ComponentForm):
+    device = forms.ModelChoiceField(
+        queryset=Device.objects.all(),
+        widget=forms.HiddenInput()
+    )
     name_pattern = ExpandableNameField(
     name_pattern = ExpandableNameField(
         label='Name'
         label='Name'
     )
     )
@@ -3680,6 +3715,10 @@ class DeviceBayForm(BootstrapMixin, forms.ModelForm):
 
 
 
 
 class DeviceBayCreateForm(ComponentForm):
 class DeviceBayCreateForm(ComponentForm):
+    device = forms.ModelChoiceField(
+        queryset=Device.objects.all(),
+        widget=forms.HiddenInput()
+    )
     name_pattern = ExpandableNameField(
     name_pattern = ExpandableNameField(
         label='Name'
         label='Name'
     )
     )

+ 149 - 61
netbox/dcim/tests/test_views.py

@@ -682,11 +682,11 @@ class ConsolePortTestCase(StandardTestCases.Views):
 
 
     # Disable inapplicable views
     # Disable inapplicable views
     test_get_object = None
     test_get_object = None
+    test_create_object = None
     test_bulk_edit_objects = None
     test_bulk_edit_objects = None
 
 
-    # TODO
-    test_create_object = None
-    test_bulk_delete_objects = None
+    def test_bulk_create_objects(self):
+        return self._test_bulk_create_objects(expected_count=3)
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
@@ -704,11 +704,14 @@ class ConsolePortTestCase(StandardTestCases.Views):
             'type': ConsolePortTypeChoices.TYPE_RJ45,
             'type': ConsolePortTypeChoices.TYPE_RJ45,
             'description': 'A console port',
             'description': 'A console port',
             'tags': 'Alpha,Bravo,Charlie',
             'tags': 'Alpha,Bravo,Charlie',
+        }
 
 
-            # Extraneous model fields
-            'cable': None,
-            'connected_endpoint': None,
-            'connection_status': None,
+        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',
         }
         }
 
 
         cls.csv_data = (
         cls.csv_data = (
@@ -724,11 +727,10 @@ class ConsoleServerPortTestCase(StandardTestCases.Views):
 
 
     # Disable inapplicable views
     # Disable inapplicable views
     test_get_object = None
     test_get_object = None
-
-    # TODO
     test_create_object = None
     test_create_object = None
-    test_bulk_edit_objects = None
-    test_bulk_delete_objects = None
+
+    def test_bulk_create_objects(self):
+        return self._test_bulk_create_objects(expected_count=3)
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
@@ -746,10 +748,20 @@ class ConsoleServerPortTestCase(StandardTestCases.Views):
             'type': ConsolePortTypeChoices.TYPE_RJ45,
             'type': ConsolePortTypeChoices.TYPE_RJ45,
             'description': 'A console server port',
             'description': 'A console server port',
             'tags': 'Alpha,Bravo,Charlie',
             'tags': 'Alpha,Bravo,Charlie',
+        }
 
 
-            # Extraneous model fields
-            'cable': None,
-            'connection_status': None,
+        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,
+            'description': 'New description',
         }
         }
 
 
         cls.csv_data = (
         cls.csv_data = (
@@ -766,10 +778,10 @@ class PowerPortTestCase(StandardTestCases.Views):
     # Disable inapplicable views
     # Disable inapplicable views
     test_get_object = None
     test_get_object = None
     test_bulk_edit_objects = None
     test_bulk_edit_objects = None
-
-    # TODO
     test_create_object = None
     test_create_object = None
-    test_bulk_delete_objects = None
+
+    def test_bulk_create_objects(self):
+        return self._test_bulk_create_objects(expected_count=3)
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
@@ -789,10 +801,16 @@ class PowerPortTestCase(StandardTestCases.Views):
             'allocated_draw': 50,
             'allocated_draw': 50,
             'description': 'A power port',
             'description': 'A power port',
             'tags': 'Alpha,Bravo,Charlie',
             'tags': 'Alpha,Bravo,Charlie',
+        }
 
 
-            # Extraneous model fields
-            'cable': None,
-            'connection_status': None,
+        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',
         }
         }
 
 
         cls.csv_data = (
         cls.csv_data = (
@@ -808,11 +826,10 @@ class PowerOutletTestCase(StandardTestCases.Views):
 
 
     # Disable inapplicable views
     # Disable inapplicable views
     test_get_object = None
     test_get_object = None
-
-    # TODO
     test_create_object = None
     test_create_object = None
-    test_bulk_edit_objects = None
-    test_bulk_delete_objects = None
+
+    def test_bulk_create_objects(self):
+        return self._test_bulk_create_objects(expected_count=3)
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
@@ -838,10 +855,24 @@ class PowerOutletTestCase(StandardTestCases.Views):
             'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_B,
             'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_B,
             'description': 'A power outlet',
             'description': 'A power outlet',
             'tags': 'Alpha,Bravo,Charlie',
             'tags': 'Alpha,Bravo,Charlie',
+        }
 
 
-            # Extraneous model fields
-            'cable': None,
-            'connection_status': None,
+        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,
+            'power_port': powerports[1].pk,
+            'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_B,
+            'description': 'New description',
         }
         }
 
 
         cls.csv_data = (
         cls.csv_data = (
@@ -855,20 +886,23 @@ class PowerOutletTestCase(StandardTestCases.Views):
 class InterfaceTestCase(StandardTestCases.Views):
 class InterfaceTestCase(StandardTestCases.Views):
     model = Interface
     model = Interface
 
 
-    # TODO
+    # Disable inapplicable views
     test_create_object = None
     test_create_object = None
-    test_bulk_edit_objects = None
-    test_bulk_delete_objects = None
+
+    def test_bulk_create_objects(self):
+        return self._test_bulk_create_objects(expected_count=3)
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
         device = create_test_device('Device 1')
         device = create_test_device('Device 1')
 
 
-        Interface.objects.bulk_create([
+        interfaces = (
             Interface(device=device, name='Interface 1'),
             Interface(device=device, name='Interface 1'),
             Interface(device=device, name='Interface 2'),
             Interface(device=device, name='Interface 2'),
             Interface(device=device, name='Interface 3'),
             Interface(device=device, name='Interface 3'),
-        ])
+            Interface(device=device, name='LAG', type=InterfaceTypeChoices.TYPE_LAG),
+        )
+        Interface.objects.bulk_create(interfaces)
 
 
         vlans = (
         vlans = (
             VLAN(vid=1, name='VLAN1', site=device.site),
             VLAN(vid=1, name='VLAN1', site=device.site),
@@ -884,19 +918,45 @@ class InterfaceTestCase(StandardTestCases.Views):
             'name': 'Interface X',
             'name': 'Interface X',
             'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
             'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
             'enabled': False,
             'enabled': False,
-            'lag': None,
+            'lag': interfaces[3].pk,
             'mac_address': EUI('01:02:03:04:05:06'),
             'mac_address': EUI('01:02:03:04:05:06'),
             'mtu': 2000,
             'mtu': 2000,
             'mgmt_only': True,
             'mgmt_only': True,
-            'description': 'New description',
+            'description': 'A front port',
             'mode': InterfaceModeChoices.MODE_TAGGED,
             'mode': InterfaceModeChoices.MODE_TAGGED,
             'untagged_vlan': vlans[0].pk,
             'untagged_vlan': vlans[0].pk,
             'tagged_vlans': [v.pk for v in vlans[1:4]],
             'tagged_vlans': [v.pk for v in vlans[1:4]],
             'tags': 'Alpha,Bravo,Charlie',
             'tags': 'Alpha,Bravo,Charlie',
+        }
 
 
-            # Extraneous model fields
-            'cable': None,
-            'connection_status': None,
+        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,
+            'enabled': False,
+            'lag': interfaces[3].pk,
+            'mac_address': EUI('01:02:03:04:05:06'),
+            'mtu': 2000,
+            'mgmt_only': True,
+            'description': 'New description',
+            'mode': InterfaceModeChoices.MODE_TAGGED,
+            'untagged_vlan': vlans[0].pk,
+            'tagged_vlans': [v.pk for v in vlans[1:4]],
         }
         }
 
 
         cls.csv_data = (
         cls.csv_data = (
@@ -912,11 +972,10 @@ class FrontPortTestCase(StandardTestCases.Views):
 
 
     # Disable inapplicable views
     # Disable inapplicable views
     test_get_object = None
     test_get_object = None
-
-    # TODO
     test_create_object = None
     test_create_object = None
-    test_bulk_edit_objects = None
-    test_bulk_delete_objects = None
+
+    def test_bulk_create_objects(self):
+        return self._test_bulk_create_objects(expected_count=3)
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
@@ -946,9 +1005,22 @@ class FrontPortTestCase(StandardTestCases.Views):
             'rear_port_position': 1,
             'rear_port_position': 1,
             'description': 'New description',
             'description': 'New description',
             'tags': 'Alpha,Bravo,Charlie',
             'tags': 'Alpha,Bravo,Charlie',
+        }
 
 
-            # Extraneous model fields
-            'cable': None,
+        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',
         }
         }
 
 
         cls.csv_data = (
         cls.csv_data = (
@@ -964,11 +1036,10 @@ class RearPortTestCase(StandardTestCases.Views):
 
 
     # Disable inapplicable views
     # Disable inapplicable views
     test_get_object = None
     test_get_object = None
-
-    # TODO
     test_create_object = None
     test_create_object = None
-    test_bulk_edit_objects = None
-    test_bulk_delete_objects = None
+
+    def test_bulk_create_objects(self):
+        return self._test_bulk_create_objects(expected_count=3)
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
@@ -985,11 +1056,22 @@ class RearPortTestCase(StandardTestCases.Views):
             'name': 'Rear Port X',
             'name': 'Rear Port X',
             'type': PortTypeChoices.TYPE_8P8C,
             'type': PortTypeChoices.TYPE_8P8C,
             'positions': 3,
             'positions': 3,
-            'description': 'New description',
+            'description': 'A rear port',
             'tags': 'Alpha,Bravo,Charlie',
             'tags': 'Alpha,Bravo,Charlie',
+        }
 
 
-            # Extraneous model fields
-            'cable': None,
+        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',
         }
         }
 
 
         cls.csv_data = (
         cls.csv_data = (
@@ -1005,11 +1087,13 @@ class DeviceBayTestCase(StandardTestCases.Views):
 
 
     # Disable inapplicable views
     # Disable inapplicable views
     test_get_object = None
     test_get_object = None
+    test_create_object = None
 
 
     # TODO
     # TODO
-    test_create_object = None
     test_bulk_edit_objects = None
     test_bulk_edit_objects = None
-    test_bulk_delete_objects = None
+
+    def test_bulk_create_objects(self):
+        return self._test_bulk_create_objects(expected_count=3)
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
@@ -1030,9 +1114,13 @@ class DeviceBayTestCase(StandardTestCases.Views):
             'name': 'Device Bay X',
             'name': 'Device Bay X',
             'description': 'A device bay',
             'description': 'A device bay',
             'tags': 'Alpha,Bravo,Charlie',
             'tags': 'Alpha,Bravo,Charlie',
+        }
 
 
-            # Extraneous model fields
-            'installed_device': None,
+        cls.bulk_create_data = {
+            'device': device2.pk,
+            'name_pattern': 'Device Bay [4-6]',
+            'description': 'A device bay',
+            'tags': 'Alpha,Bravo,Charlie',
         }
         }
 
 
         cls.csv_data = (
         cls.csv_data = (
@@ -1076,13 +1164,6 @@ class InventoryItemTestCase(StandardTestCases.Views):
             'tags': 'Alpha,Bravo,Charlie',
             'tags': 'Alpha,Bravo,Charlie',
         }
         }
 
 
-        cls.csv_data = (
-            "device,name",
-            "Device 1,Inventory Item 4",
-            "Device 1,Inventory Item 5",
-            "Device 1,Inventory Item 6",
-        )
-
         cls.bulk_edit_data = {
         cls.bulk_edit_data = {
             'device': device.pk,
             'device': device.pk,
             'manufacturer': manufacturer.pk,
             'manufacturer': manufacturer.pk,
@@ -1090,6 +1171,13 @@ class InventoryItemTestCase(StandardTestCases.Views):
             'description': 'New description',
             'description': 'New description',
         }
         }
 
 
+        cls.csv_data = (
+            "device,name",
+            "Device 1,Inventory Item 4",
+            "Device 1,Inventory Item 5",
+            "Device 1,Inventory Item 6",
+        )
+
 
 
 class CableTestCase(StandardTestCases.Views):
 class CableTestCase(StandardTestCases.Views):
     model = Cable
     model = Cable

+ 44 - 40
netbox/dcim/urls.py

@@ -169,109 +169,112 @@ urlpatterns = [
 
 
     # Console ports
     # Console ports
     path(r'devices/console-ports/add/', views.DeviceBulkAddConsolePortView.as_view(), name='device_bulk_add_consoleport'),
     path(r'devices/console-ports/add/', views.DeviceBulkAddConsolePortView.as_view(), name='device_bulk_add_consoleport'),
-    path(r'devices/<int:pk>/console-ports/add/', views.ConsolePortCreateView.as_view(), name='consoleport_add'),
-    path(r'devices/<int:pk>/console-ports/delete/', views.ConsolePortBulkDeleteView.as_view(), name='consoleport_bulk_delete'),
     path(r'console-ports/', views.ConsolePortListView.as_view(), name='consoleport_list'),
     path(r'console-ports/', views.ConsolePortListView.as_view(), name='consoleport_list'),
+    path(r'console-ports/add/', views.ConsolePortCreateView.as_view(), name='consoleport_add'),
+    path(r'console-ports/import/', views.ConsolePortBulkImportView.as_view(), name='consoleport_import'),
+    # TODO: Bulk edit view for ConsolePorts
+    path(r'console-ports/delete/', views.ConsolePortBulkDeleteView.as_view(), name='consoleport_bulk_delete'),
     path(r'console-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='consoleport_connect', kwargs={'termination_a_type': ConsolePort}),
     path(r'console-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='consoleport_connect', kwargs={'termination_a_type': ConsolePort}),
     path(r'console-ports/<int:pk>/edit/', views.ConsolePortEditView.as_view(), name='consoleport_edit'),
     path(r'console-ports/<int:pk>/edit/', views.ConsolePortEditView.as_view(), name='consoleport_edit'),
     path(r'console-ports/<int:pk>/delete/', views.ConsolePortDeleteView.as_view(), name='consoleport_delete'),
     path(r'console-ports/<int:pk>/delete/', views.ConsolePortDeleteView.as_view(), name='consoleport_delete'),
     path(r'console-ports/<int:pk>/trace/', views.CableTraceView.as_view(), name='consoleport_trace', kwargs={'model': ConsolePort}),
     path(r'console-ports/<int:pk>/trace/', views.CableTraceView.as_view(), name='consoleport_trace', kwargs={'model': ConsolePort}),
-    path(r'console-ports/import/', views.ConsolePortBulkImportView.as_view(), name='consoleport_import'),
 
 
     # Console server ports
     # Console server ports
     path(r'devices/console-server-ports/add/', views.DeviceBulkAddConsoleServerPortView.as_view(), name='device_bulk_add_consoleserverport'),
     path(r'devices/console-server-ports/add/', views.DeviceBulkAddConsoleServerPortView.as_view(), name='device_bulk_add_consoleserverport'),
-    path(r'devices/<int:pk>/console-server-ports/add/', views.ConsoleServerPortCreateView.as_view(), name='consoleserverport_add'),
-    path(r'devices/<int:pk>/console-server-ports/edit/', views.ConsoleServerPortBulkEditView.as_view(), name='consoleserverport_bulk_edit'),
-    path(r'devices/<int:pk>/console-server-ports/delete/', views.ConsoleServerPortBulkDeleteView.as_view(), name='consoleserverport_bulk_delete'),
     path(r'console-server-ports/', views.ConsoleServerPortListView.as_view(), name='consoleserverport_list'),
     path(r'console-server-ports/', views.ConsoleServerPortListView.as_view(), name='consoleserverport_list'),
+    path(r'console-server-ports/rename/', views.ConsoleServerPortBulkRenameView.as_view(), name='consoleserverport_bulk_rename'),
+    path(r'console-server-ports/disconnect/', views.ConsoleServerPortBulkDisconnectView.as_view(), name='consoleserverport_bulk_disconnect'),
+    path(r'console-server-ports/add/', views.ConsoleServerPortCreateView.as_view(), name='consoleserverport_add'),
+    path(r'console-server-ports/import/', views.ConsoleServerPortBulkImportView.as_view(), name='consoleserverport_import'),
+    path(r'console-server-ports/edit/', views.ConsoleServerPortBulkEditView.as_view(), name='consoleserverport_bulk_edit'),
+    path(r'console-server-ports/delete/', views.ConsoleServerPortBulkDeleteView.as_view(), name='consoleserverport_bulk_delete'),
     path(r'console-server-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='consoleserverport_connect', kwargs={'termination_a_type': ConsoleServerPort}),
     path(r'console-server-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='consoleserverport_connect', kwargs={'termination_a_type': ConsoleServerPort}),
     path(r'console-server-ports/<int:pk>/edit/', views.ConsoleServerPortEditView.as_view(), name='consoleserverport_edit'),
     path(r'console-server-ports/<int:pk>/edit/', views.ConsoleServerPortEditView.as_view(), name='consoleserverport_edit'),
     path(r'console-server-ports/<int:pk>/delete/', views.ConsoleServerPortDeleteView.as_view(), name='consoleserverport_delete'),
     path(r'console-server-ports/<int:pk>/delete/', views.ConsoleServerPortDeleteView.as_view(), name='consoleserverport_delete'),
     path(r'console-server-ports/<int:pk>/trace/', views.CableTraceView.as_view(), name='consoleserverport_trace', kwargs={'model': ConsoleServerPort}),
     path(r'console-server-ports/<int:pk>/trace/', views.CableTraceView.as_view(), name='consoleserverport_trace', kwargs={'model': ConsoleServerPort}),
-    path(r'console-server-ports/rename/', views.ConsoleServerPortBulkRenameView.as_view(), name='consoleserverport_bulk_rename'),
-    path(r'console-server-ports/disconnect/', views.ConsoleServerPortBulkDisconnectView.as_view(), name='consoleserverport_bulk_disconnect'),
-    path(r'console-server-ports/import/', views.ConsoleServerPortBulkImportView.as_view(), name='consoleserverport_import'),
 
 
     # Power ports
     # Power ports
     path(r'devices/power-ports/add/', views.DeviceBulkAddPowerPortView.as_view(), name='device_bulk_add_powerport'),
     path(r'devices/power-ports/add/', views.DeviceBulkAddPowerPortView.as_view(), name='device_bulk_add_powerport'),
-    path(r'devices/<int:pk>/power-ports/add/', views.PowerPortCreateView.as_view(), name='powerport_add'),
-    path(r'devices/<int:pk>/power-ports/delete/', views.PowerPortBulkDeleteView.as_view(), name='powerport_bulk_delete'),
     path(r'power-ports/', views.PowerPortListView.as_view(), name='powerport_list'),
     path(r'power-ports/', views.PowerPortListView.as_view(), name='powerport_list'),
+    path(r'power-ports/add/', views.PowerPortCreateView.as_view(), name='powerport_add'),
+    path(r'power-ports/import/', views.PowerPortBulkImportView.as_view(), name='powerport_import'),
+    # TODO: Bulk edit view for PowerPorts
+    path(r'power-ports/delete/', views.PowerPortBulkDeleteView.as_view(), name='powerport_bulk_delete'),
     path(r'power-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='powerport_connect', kwargs={'termination_a_type': PowerPort}),
     path(r'power-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='powerport_connect', kwargs={'termination_a_type': PowerPort}),
     path(r'power-ports/<int:pk>/edit/', views.PowerPortEditView.as_view(), name='powerport_edit'),
     path(r'power-ports/<int:pk>/edit/', views.PowerPortEditView.as_view(), name='powerport_edit'),
     path(r'power-ports/<int:pk>/delete/', views.PowerPortDeleteView.as_view(), name='powerport_delete'),
     path(r'power-ports/<int:pk>/delete/', views.PowerPortDeleteView.as_view(), name='powerport_delete'),
     path(r'power-ports/<int:pk>/trace/', views.CableTraceView.as_view(), name='powerport_trace', kwargs={'model': PowerPort}),
     path(r'power-ports/<int:pk>/trace/', views.CableTraceView.as_view(), name='powerport_trace', kwargs={'model': PowerPort}),
-    path(r'power-ports/import/', views.PowerPortBulkImportView.as_view(), name='powerport_import'),
 
 
     # Power outlets
     # Power outlets
     path(r'devices/power-outlets/add/', views.DeviceBulkAddPowerOutletView.as_view(), name='device_bulk_add_poweroutlet'),
     path(r'devices/power-outlets/add/', views.DeviceBulkAddPowerOutletView.as_view(), name='device_bulk_add_poweroutlet'),
-    path(r'devices/<int:pk>/power-outlets/add/', views.PowerOutletCreateView.as_view(), name='poweroutlet_add'),
-    path(r'devices/<int:pk>/power-outlets/edit/', views.PowerOutletBulkEditView.as_view(), name='poweroutlet_bulk_edit'),
-    path(r'devices/<int:pk>/power-outlets/delete/', views.PowerOutletBulkDeleteView.as_view(), name='poweroutlet_bulk_delete'),
     path(r'power-outlets/', views.PowerOutletListView.as_view(), name='poweroutlet_list'),
     path(r'power-outlets/', views.PowerOutletListView.as_view(), name='poweroutlet_list'),
+    path(r'power-outlets/rename/', views.PowerOutletBulkRenameView.as_view(), name='poweroutlet_bulk_rename'),
+    path(r'power-outlets/disconnect/', views.PowerOutletBulkDisconnectView.as_view(), name='poweroutlet_bulk_disconnect'),
+    path(r'power-outlets/add/', views.PowerOutletCreateView.as_view(), name='poweroutlet_add'),
+    path(r'power-outlets/import/', views.PowerOutletBulkImportView.as_view(), name='poweroutlet_import'),
+    path(r'power-outlets/edit/', views.PowerOutletBulkEditView.as_view(), name='poweroutlet_bulk_edit'),
+    path(r'power-outlets/delete/', views.PowerOutletBulkDeleteView.as_view(), name='poweroutlet_bulk_delete'),
     path(r'power-outlets/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='poweroutlet_connect', kwargs={'termination_a_type': PowerOutlet}),
     path(r'power-outlets/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='poweroutlet_connect', kwargs={'termination_a_type': PowerOutlet}),
     path(r'power-outlets/<int:pk>/edit/', views.PowerOutletEditView.as_view(), name='poweroutlet_edit'),
     path(r'power-outlets/<int:pk>/edit/', views.PowerOutletEditView.as_view(), name='poweroutlet_edit'),
     path(r'power-outlets/<int:pk>/delete/', views.PowerOutletDeleteView.as_view(), name='poweroutlet_delete'),
     path(r'power-outlets/<int:pk>/delete/', views.PowerOutletDeleteView.as_view(), name='poweroutlet_delete'),
     path(r'power-outlets/<int:pk>/trace/', views.CableTraceView.as_view(), name='poweroutlet_trace', kwargs={'model': PowerOutlet}),
     path(r'power-outlets/<int:pk>/trace/', views.CableTraceView.as_view(), name='poweroutlet_trace', kwargs={'model': PowerOutlet}),
-    path(r'power-outlets/rename/', views.PowerOutletBulkRenameView.as_view(), name='poweroutlet_bulk_rename'),
-    path(r'power-outlets/disconnect/', views.PowerOutletBulkDisconnectView.as_view(), name='poweroutlet_bulk_disconnect'),
-    path(r'power-outlets/import/', views.PowerOutletBulkImportView.as_view(), name='poweroutlet_import'),
 
 
     # Interfaces
     # Interfaces
     path(r'devices/interfaces/add/', views.DeviceBulkAddInterfaceView.as_view(), name='device_bulk_add_interface'),
     path(r'devices/interfaces/add/', views.DeviceBulkAddInterfaceView.as_view(), name='device_bulk_add_interface'),
-    path(r'devices/<int:pk>/interfaces/add/', views.InterfaceCreateView.as_view(), name='interface_add'),
-    path(r'devices/<int:pk>/interfaces/edit/', views.InterfaceBulkEditView.as_view(), name='interface_bulk_edit'),
-    path(r'devices/<int:pk>/interfaces/delete/', views.InterfaceBulkDeleteView.as_view(), name='interface_bulk_delete'),
     path(r'interfaces/', views.InterfaceListView.as_view(), name='interface_list'),
     path(r'interfaces/', views.InterfaceListView.as_view(), name='interface_list'),
+    path(r'interfaces/rename/', views.InterfaceBulkRenameView.as_view(), name='interface_bulk_rename'),
+    path(r'interfaces/disconnect/', views.InterfaceBulkDisconnectView.as_view(), name='interface_bulk_disconnect'),
+    path(r'interfaces/add/', views.InterfaceCreateView.as_view(), name='interface_add'),
+    path(r'interfaces/import/', views.InterfaceBulkImportView.as_view(), name='interface_import'),
+    path(r'interfaces/edit/', views.InterfaceBulkEditView.as_view(), name='interface_bulk_edit'),
+    path(r'interfaces/delete/', views.InterfaceBulkDeleteView.as_view(), name='interface_bulk_delete'),
     path(r'interfaces/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='interface_connect', kwargs={'termination_a_type': Interface}),
     path(r'interfaces/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='interface_connect', kwargs={'termination_a_type': Interface}),
     path(r'interfaces/<int:pk>/', views.InterfaceView.as_view(), name='interface'),
     path(r'interfaces/<int:pk>/', views.InterfaceView.as_view(), name='interface'),
     path(r'interfaces/<int:pk>/edit/', views.InterfaceEditView.as_view(), name='interface_edit'),
     path(r'interfaces/<int:pk>/edit/', views.InterfaceEditView.as_view(), name='interface_edit'),
     path(r'interfaces/<int:pk>/delete/', views.InterfaceDeleteView.as_view(), name='interface_delete'),
     path(r'interfaces/<int:pk>/delete/', views.InterfaceDeleteView.as_view(), name='interface_delete'),
     path(r'interfaces/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='interface_changelog', kwargs={'model': Interface}),
     path(r'interfaces/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='interface_changelog', kwargs={'model': Interface}),
     path(r'interfaces/<int:pk>/trace/', views.CableTraceView.as_view(), name='interface_trace', kwargs={'model': Interface}),
     path(r'interfaces/<int:pk>/trace/', views.CableTraceView.as_view(), name='interface_trace', kwargs={'model': Interface}),
-    path(r'interfaces/rename/', views.InterfaceBulkRenameView.as_view(), name='interface_bulk_rename'),
-    path(r'interfaces/disconnect/', views.InterfaceBulkDisconnectView.as_view(), name='interface_bulk_disconnect'),
-    path(r'interfaces/import/', views.InterfaceBulkImportView.as_view(), name='interface_import'),
 
 
     # Front ports
     # Front ports
     # path(r'devices/front-ports/add/', views.DeviceBulkAddFrontPortView.as_view(), name='device_bulk_add_frontport'),
     # path(r'devices/front-ports/add/', views.DeviceBulkAddFrontPortView.as_view(), name='device_bulk_add_frontport'),
-    path(r'devices/<int:pk>/front-ports/add/', views.FrontPortCreateView.as_view(), name='frontport_add'),
-    path(r'devices/<int:pk>/front-ports/edit/', views.FrontPortBulkEditView.as_view(), name='frontport_bulk_edit'),
-    path(r'devices/<int:pk>/front-ports/delete/', views.FrontPortBulkDeleteView.as_view(), name='frontport_bulk_delete'),
     path(r'front-ports/', views.FrontPortListView.as_view(), name='frontport_list'),
     path(r'front-ports/', views.FrontPortListView.as_view(), name='frontport_list'),
+    path(r'front-ports/rename/', views.FrontPortBulkRenameView.as_view(), name='frontport_bulk_rename'),
+    path(r'front-ports/disconnect/', views.FrontPortBulkDisconnectView.as_view(), name='frontport_bulk_disconnect'),
+    path(r'front-ports/add/', views.FrontPortCreateView.as_view(), name='frontport_add'),
+    path(r'front-ports/import/', views.FrontPortBulkImportView.as_view(), name='frontport_import'),
+    path(r'front-ports/edit/', views.FrontPortBulkEditView.as_view(), name='frontport_bulk_edit'),
+    path(r'front-ports/delete/', views.FrontPortBulkDeleteView.as_view(), name='frontport_bulk_delete'),
     path(r'front-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='frontport_connect', kwargs={'termination_a_type': FrontPort}),
     path(r'front-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='frontport_connect', kwargs={'termination_a_type': FrontPort}),
     path(r'front-ports/<int:pk>/edit/', views.FrontPortEditView.as_view(), name='frontport_edit'),
     path(r'front-ports/<int:pk>/edit/', views.FrontPortEditView.as_view(), name='frontport_edit'),
     path(r'front-ports/<int:pk>/delete/', views.FrontPortDeleteView.as_view(), name='frontport_delete'),
     path(r'front-ports/<int:pk>/delete/', views.FrontPortDeleteView.as_view(), name='frontport_delete'),
     path(r'front-ports/<int:pk>/trace/', views.CableTraceView.as_view(), name='frontport_trace', kwargs={'model': FrontPort}),
     path(r'front-ports/<int:pk>/trace/', views.CableTraceView.as_view(), name='frontport_trace', kwargs={'model': FrontPort}),
-    path(r'front-ports/rename/', views.FrontPortBulkRenameView.as_view(), name='frontport_bulk_rename'),
-    path(r'front-ports/disconnect/', views.FrontPortBulkDisconnectView.as_view(), name='frontport_bulk_disconnect'),
-    path(r'front-ports/import/', views.FrontPortBulkImportView.as_view(), name='frontport_import'),
 
 
     # Rear ports
     # Rear ports
     # path(r'devices/rear-ports/add/', views.DeviceBulkAddRearPortView.as_view(), name='device_bulk_add_rearport'),
     # path(r'devices/rear-ports/add/', views.DeviceBulkAddRearPortView.as_view(), name='device_bulk_add_rearport'),
-    path(r'devices/<int:pk>/rear-ports/add/', views.RearPortCreateView.as_view(), name='rearport_add'),
-    path(r'devices/<int:pk>/rear-ports/edit/', views.RearPortBulkEditView.as_view(), name='rearport_bulk_edit'),
-    path(r'devices/<int:pk>/rear-ports/delete/', views.RearPortBulkDeleteView.as_view(), name='rearport_bulk_delete'),
     path(r'rear-ports/', views.RearPortListView.as_view(), name='rearport_list'),
     path(r'rear-ports/', views.RearPortListView.as_view(), name='rearport_list'),
+    path(r'rear-ports/rename/', views.RearPortBulkRenameView.as_view(), name='rearport_bulk_rename'),
+    path(r'rear-ports/disconnect/', views.RearPortBulkDisconnectView.as_view(), name='rearport_bulk_disconnect'),
+    path(r'rear-ports/add/', views.RearPortCreateView.as_view(), name='rearport_add'),
+    path(r'rear-ports/import/', views.RearPortBulkImportView.as_view(), name='rearport_import'),
+    path(r'rear-ports/edit/', views.RearPortBulkEditView.as_view(), name='rearport_bulk_edit'),
+    path(r'rear-ports/delete/', views.RearPortBulkDeleteView.as_view(), name='rearport_bulk_delete'),
     path(r'rear-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='rearport_connect', kwargs={'termination_a_type': RearPort}),
     path(r'rear-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='rearport_connect', kwargs={'termination_a_type': RearPort}),
     path(r'rear-ports/<int:pk>/edit/', views.RearPortEditView.as_view(), name='rearport_edit'),
     path(r'rear-ports/<int:pk>/edit/', views.RearPortEditView.as_view(), name='rearport_edit'),
     path(r'rear-ports/<int:pk>/delete/', views.RearPortDeleteView.as_view(), name='rearport_delete'),
     path(r'rear-ports/<int:pk>/delete/', views.RearPortDeleteView.as_view(), name='rearport_delete'),
     path(r'rear-ports/<int:pk>/trace/', views.CableTraceView.as_view(), name='rearport_trace', kwargs={'model': RearPort}),
     path(r'rear-ports/<int:pk>/trace/', views.CableTraceView.as_view(), name='rearport_trace', kwargs={'model': RearPort}),
-    path(r'rear-ports/rename/', views.RearPortBulkRenameView.as_view(), name='rearport_bulk_rename'),
-    path(r'rear-ports/disconnect/', views.RearPortBulkDisconnectView.as_view(), name='rearport_bulk_disconnect'),
-    path(r'rear-ports/import/', views.RearPortBulkImportView.as_view(), name='rearport_import'),
 
 
     # Device bays
     # Device bays
     path(r'devices/device-bays/add/', views.DeviceBulkAddDeviceBayView.as_view(), name='device_bulk_add_devicebay'),
     path(r'devices/device-bays/add/', views.DeviceBulkAddDeviceBayView.as_view(), name='device_bulk_add_devicebay'),
-    path(r'devices/<int:pk>/bays/add/', views.DeviceBayCreateView.as_view(), name='devicebay_add'),
-    path(r'devices/<int:pk>/bays/delete/', views.DeviceBayBulkDeleteView.as_view(), name='devicebay_bulk_delete'),
     path(r'device-bays/', views.DeviceBayListView.as_view(), name='devicebay_list'),
     path(r'device-bays/', views.DeviceBayListView.as_view(), name='devicebay_list'),
+    path(r'device-bays/rename/', views.DeviceBayBulkRenameView.as_view(), name='devicebay_bulk_rename'),
+    path(r'device-bays/add/', views.DeviceBayCreateView.as_view(), name='devicebay_add'),
+    path(r'device-bays/import/', views.DeviceBayBulkImportView.as_view(), name='devicebay_import'),
+    # TODO: Bulk edit view for DeviceBays
+    path(r'device-bays/delete/', views.DeviceBayBulkDeleteView.as_view(), name='devicebay_bulk_delete'),
     path(r'device-bays/<int:pk>/edit/', views.DeviceBayEditView.as_view(), name='devicebay_edit'),
     path(r'device-bays/<int:pk>/edit/', views.DeviceBayEditView.as_view(), name='devicebay_edit'),
     path(r'device-bays/<int:pk>/delete/', views.DeviceBayDeleteView.as_view(), name='devicebay_delete'),
     path(r'device-bays/<int:pk>/delete/', views.DeviceBayDeleteView.as_view(), name='devicebay_delete'),
     path(r'device-bays/<int:pk>/populate/', views.DeviceBayPopulateView.as_view(), name='devicebay_populate'),
     path(r'device-bays/<int:pk>/populate/', views.DeviceBayPopulateView.as_view(), name='devicebay_populate'),
     path(r'device-bays/<int:pk>/depopulate/', views.DeviceBayDepopulateView.as_view(), name='devicebay_depopulate'),
     path(r'device-bays/<int:pk>/depopulate/', views.DeviceBayDepopulateView.as_view(), name='devicebay_depopulate'),
-    path(r'device-bays/rename/', views.DeviceBayBulkRenameView.as_view(), name='devicebay_bulk_rename'),
-    path(r'device-bays/import/', views.DeviceBayBulkImportView.as_view(), name='devicebay_import'),
 
 
     # Inventory items
     # Inventory items
     path(r'inventory-items/', views.InventoryItemListView.as_view(), name='inventoryitem_list'),
     path(r'inventory-items/', views.InventoryItemListView.as_view(), name='inventoryitem_list'),
@@ -280,6 +283,7 @@ urlpatterns = [
     path(r'inventory-items/delete/', views.InventoryItemBulkDeleteView.as_view(), name='inventoryitem_bulk_delete'),
     path(r'inventory-items/delete/', views.InventoryItemBulkDeleteView.as_view(), name='inventoryitem_bulk_delete'),
     path(r'inventory-items/<int:pk>/edit/', views.InventoryItemEditView.as_view(), name='inventoryitem_edit'),
     path(r'inventory-items/<int:pk>/edit/', views.InventoryItemEditView.as_view(), name='inventoryitem_edit'),
     path(r'inventory-items/<int:pk>/delete/', views.InventoryItemDeleteView.as_view(), name='inventoryitem_delete'),
     path(r'inventory-items/<int:pk>/delete/', views.InventoryItemDeleteView.as_view(), name='inventoryitem_delete'),
+    # TODO: Replace below with InventoryItemCreateView
     path(r'devices/<int:device>/inventory-items/add/', views.InventoryItemEditView.as_view(), name='inventoryitem_add'),
     path(r'devices/<int:device>/inventory-items/add/', views.InventoryItemEditView.as_view(), name='inventoryitem_add'),
 
 
     # Cables
     # Cables

+ 8 - 29
netbox/dcim/views.py

@@ -1205,8 +1205,6 @@ class ConsolePortListView(PermissionRequiredMixin, ObjectListView):
 
 
 class ConsolePortCreateView(PermissionRequiredMixin, ComponentCreateView):
 class ConsolePortCreateView(PermissionRequiredMixin, ComponentCreateView):
     permission_required = 'dcim.add_consoleport'
     permission_required = 'dcim.add_consoleport'
-    parent_model = Device
-    parent_field = 'device'
     model = ConsolePort
     model = ConsolePort
     form = forms.ConsolePortCreateForm
     form = forms.ConsolePortCreateForm
     model_form = forms.ConsolePortForm
     model_form = forms.ConsolePortForm
@@ -1234,8 +1232,8 @@ class ConsolePortBulkImportView(PermissionRequiredMixin, BulkImportView):
 class ConsolePortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class ConsolePortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_consoleport'
     permission_required = 'dcim.delete_consoleport'
     queryset = ConsolePort.objects.all()
     queryset = ConsolePort.objects.all()
-    parent_model = Device
     table = tables.ConsolePortTable
     table = tables.ConsolePortTable
+    default_return_url = 'dcim:consoleport_list'
 
 
 
 
 #
 #
@@ -1253,8 +1251,6 @@ class ConsoleServerPortListView(PermissionRequiredMixin, ObjectListView):
 
 
 class ConsoleServerPortCreateView(PermissionRequiredMixin, ComponentCreateView):
 class ConsoleServerPortCreateView(PermissionRequiredMixin, ComponentCreateView):
     permission_required = 'dcim.add_consoleserverport'
     permission_required = 'dcim.add_consoleserverport'
-    parent_model = Device
-    parent_field = 'device'
     model = ConsoleServerPort
     model = ConsoleServerPort
     form = forms.ConsoleServerPortCreateForm
     form = forms.ConsoleServerPortCreateForm
     model_form = forms.ConsoleServerPortForm
     model_form = forms.ConsoleServerPortForm
@@ -1282,7 +1278,6 @@ class ConsoleServerPortBulkImportView(PermissionRequiredMixin, BulkImportView):
 class ConsoleServerPortBulkEditView(PermissionRequiredMixin, BulkEditView):
 class ConsoleServerPortBulkEditView(PermissionRequiredMixin, BulkEditView):
     permission_required = 'dcim.change_consoleserverport'
     permission_required = 'dcim.change_consoleserverport'
     queryset = ConsoleServerPort.objects.all()
     queryset = ConsoleServerPort.objects.all()
-    parent_model = Device
     table = tables.ConsoleServerPortTable
     table = tables.ConsoleServerPortTable
     form = forms.ConsoleServerPortBulkEditForm
     form = forms.ConsoleServerPortBulkEditForm
 
 
@@ -1302,8 +1297,8 @@ class ConsoleServerPortBulkDisconnectView(PermissionRequiredMixin, BulkDisconnec
 class ConsoleServerPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class ConsoleServerPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_consoleserverport'
     permission_required = 'dcim.delete_consoleserverport'
     queryset = ConsoleServerPort.objects.all()
     queryset = ConsoleServerPort.objects.all()
-    parent_model = Device
     table = tables.ConsoleServerPortTable
     table = tables.ConsoleServerPortTable
+    default_return_url = 'dcim:consoleserverport_list'
 
 
 
 
 #
 #
@@ -1321,8 +1316,6 @@ class PowerPortListView(PermissionRequiredMixin, ObjectListView):
 
 
 class PowerPortCreateView(PermissionRequiredMixin, ComponentCreateView):
 class PowerPortCreateView(PermissionRequiredMixin, ComponentCreateView):
     permission_required = 'dcim.add_powerport'
     permission_required = 'dcim.add_powerport'
-    parent_model = Device
-    parent_field = 'device'
     model = PowerPort
     model = PowerPort
     form = forms.PowerPortCreateForm
     form = forms.PowerPortCreateForm
     model_form = forms.PowerPortForm
     model_form = forms.PowerPortForm
@@ -1350,8 +1343,8 @@ class PowerPortBulkImportView(PermissionRequiredMixin, BulkImportView):
 class PowerPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class PowerPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_powerport'
     permission_required = 'dcim.delete_powerport'
     queryset = PowerPort.objects.all()
     queryset = PowerPort.objects.all()
-    parent_model = Device
     table = tables.PowerPortTable
     table = tables.PowerPortTable
+    default_return_url = 'dcim:powerport_list'
 
 
 
 
 #
 #
@@ -1369,8 +1362,6 @@ class PowerOutletListView(PermissionRequiredMixin, ObjectListView):
 
 
 class PowerOutletCreateView(PermissionRequiredMixin, ComponentCreateView):
 class PowerOutletCreateView(PermissionRequiredMixin, ComponentCreateView):
     permission_required = 'dcim.add_poweroutlet'
     permission_required = 'dcim.add_poweroutlet'
-    parent_model = Device
-    parent_field = 'device'
     model = PowerOutlet
     model = PowerOutlet
     form = forms.PowerOutletCreateForm
     form = forms.PowerOutletCreateForm
     model_form = forms.PowerOutletForm
     model_form = forms.PowerOutletForm
@@ -1398,7 +1389,6 @@ class PowerOutletBulkImportView(PermissionRequiredMixin, BulkImportView):
 class PowerOutletBulkEditView(PermissionRequiredMixin, BulkEditView):
 class PowerOutletBulkEditView(PermissionRequiredMixin, BulkEditView):
     permission_required = 'dcim.change_poweroutlet'
     permission_required = 'dcim.change_poweroutlet'
     queryset = PowerOutlet.objects.all()
     queryset = PowerOutlet.objects.all()
-    parent_model = Device
     table = tables.PowerOutletTable
     table = tables.PowerOutletTable
     form = forms.PowerOutletBulkEditForm
     form = forms.PowerOutletBulkEditForm
 
 
@@ -1418,8 +1408,8 @@ class PowerOutletBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView)
 class PowerOutletBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class PowerOutletBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_poweroutlet'
     permission_required = 'dcim.delete_poweroutlet'
     queryset = PowerOutlet.objects.all()
     queryset = PowerOutlet.objects.all()
-    parent_model = Device
     table = tables.PowerOutletTable
     table = tables.PowerOutletTable
+    default_return_url = 'dcim:poweroutlet_list'
 
 
 
 
 #
 #
@@ -1473,8 +1463,6 @@ class InterfaceView(PermissionRequiredMixin, View):
 
 
 class InterfaceCreateView(PermissionRequiredMixin, ComponentCreateView):
 class InterfaceCreateView(PermissionRequiredMixin, ComponentCreateView):
     permission_required = 'dcim.add_interface'
     permission_required = 'dcim.add_interface'
-    parent_model = Device
-    parent_field = 'device'
     model = Interface
     model = Interface
     form = forms.InterfaceCreateForm
     form = forms.InterfaceCreateForm
     model_form = forms.InterfaceForm
     model_form = forms.InterfaceForm
@@ -1503,7 +1491,6 @@ class InterfaceBulkImportView(PermissionRequiredMixin, BulkImportView):
 class InterfaceBulkEditView(PermissionRequiredMixin, BulkEditView):
 class InterfaceBulkEditView(PermissionRequiredMixin, BulkEditView):
     permission_required = 'dcim.change_interface'
     permission_required = 'dcim.change_interface'
     queryset = Interface.objects.all()
     queryset = Interface.objects.all()
-    parent_model = Device
     table = tables.InterfaceTable
     table = tables.InterfaceTable
     form = forms.InterfaceBulkEditForm
     form = forms.InterfaceBulkEditForm
 
 
@@ -1523,8 +1510,8 @@ class InterfaceBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView):
 class InterfaceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class InterfaceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_interface'
     permission_required = 'dcim.delete_interface'
     queryset = Interface.objects.all()
     queryset = Interface.objects.all()
-    parent_model = Device
     table = tables.InterfaceTable
     table = tables.InterfaceTable
+    default_return_url = 'dcim:interface_list'
 
 
 
 
 #
 #
@@ -1542,8 +1529,6 @@ class FrontPortListView(PermissionRequiredMixin, ObjectListView):
 
 
 class FrontPortCreateView(PermissionRequiredMixin, ComponentCreateView):
 class FrontPortCreateView(PermissionRequiredMixin, ComponentCreateView):
     permission_required = 'dcim.add_frontport'
     permission_required = 'dcim.add_frontport'
-    parent_model = Device
-    parent_field = 'device'
     model = FrontPort
     model = FrontPort
     form = forms.FrontPortCreateForm
     form = forms.FrontPortCreateForm
     model_form = forms.FrontPortForm
     model_form = forms.FrontPortForm
@@ -1571,7 +1556,6 @@ class FrontPortBulkImportView(PermissionRequiredMixin, BulkImportView):
 class FrontPortBulkEditView(PermissionRequiredMixin, BulkEditView):
 class FrontPortBulkEditView(PermissionRequiredMixin, BulkEditView):
     permission_required = 'dcim.change_frontport'
     permission_required = 'dcim.change_frontport'
     queryset = FrontPort.objects.all()
     queryset = FrontPort.objects.all()
-    parent_model = Device
     table = tables.FrontPortTable
     table = tables.FrontPortTable
     form = forms.FrontPortBulkEditForm
     form = forms.FrontPortBulkEditForm
 
 
@@ -1591,8 +1575,8 @@ class FrontPortBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView):
 class FrontPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class FrontPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_frontport'
     permission_required = 'dcim.delete_frontport'
     queryset = FrontPort.objects.all()
     queryset = FrontPort.objects.all()
-    parent_model = Device
     table = tables.FrontPortTable
     table = tables.FrontPortTable
+    default_return_url = 'dcim:frontport_list'
 
 
 
 
 #
 #
@@ -1610,8 +1594,6 @@ class RearPortListView(PermissionRequiredMixin, ObjectListView):
 
 
 class RearPortCreateView(PermissionRequiredMixin, ComponentCreateView):
 class RearPortCreateView(PermissionRequiredMixin, ComponentCreateView):
     permission_required = 'dcim.add_rearport'
     permission_required = 'dcim.add_rearport'
-    parent_model = Device
-    parent_field = 'device'
     model = RearPort
     model = RearPort
     form = forms.RearPortCreateForm
     form = forms.RearPortCreateForm
     model_form = forms.RearPortForm
     model_form = forms.RearPortForm
@@ -1639,7 +1621,6 @@ class RearPortBulkImportView(PermissionRequiredMixin, BulkImportView):
 class RearPortBulkEditView(PermissionRequiredMixin, BulkEditView):
 class RearPortBulkEditView(PermissionRequiredMixin, BulkEditView):
     permission_required = 'dcim.change_rearport'
     permission_required = 'dcim.change_rearport'
     queryset = RearPort.objects.all()
     queryset = RearPort.objects.all()
-    parent_model = Device
     table = tables.RearPortTable
     table = tables.RearPortTable
     form = forms.RearPortBulkEditForm
     form = forms.RearPortBulkEditForm
 
 
@@ -1659,8 +1640,8 @@ class RearPortBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView):
 class RearPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class RearPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_rearport'
     permission_required = 'dcim.delete_rearport'
     queryset = RearPort.objects.all()
     queryset = RearPort.objects.all()
-    parent_model = Device
     table = tables.RearPortTable
     table = tables.RearPortTable
+    default_return_url = 'dcim:rearport_list'
 
 
 
 
 #
 #
@@ -1680,8 +1661,6 @@ class DeviceBayListView(PermissionRequiredMixin, ObjectListView):
 
 
 class DeviceBayCreateView(PermissionRequiredMixin, ComponentCreateView):
 class DeviceBayCreateView(PermissionRequiredMixin, ComponentCreateView):
     permission_required = 'dcim.add_devicebay'
     permission_required = 'dcim.add_devicebay'
-    parent_model = Device
-    parent_field = 'device'
     model = DeviceBay
     model = DeviceBay
     form = forms.DeviceBayCreateForm
     form = forms.DeviceBayCreateForm
     model_form = forms.DeviceBayForm
     model_form = forms.DeviceBayForm
@@ -1784,8 +1763,8 @@ class DeviceBayBulkRenameView(PermissionRequiredMixin, BulkRenameView):
 class DeviceBayBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class DeviceBayBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_devicebay'
     permission_required = 'dcim.delete_devicebay'
     queryset = DeviceBay.objects.all()
     queryset = DeviceBay.objects.all()
-    parent_model = Device
     table = tables.DeviceBayTable
     table = tables.DeviceBayTable
+    default_return_url = 'dcim:devicebay_list'
 
 
 
 
 #
 #

+ 49 - 29
netbox/templates/dcim/device.html

@@ -48,14 +48,30 @@
                     <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add Components <span class="caret"></span>
                     <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add Components <span class="caret"></span>
                 </button>
                 </button>
                 <ul class="dropdown-menu">
                 <ul class="dropdown-menu">
-                    {% if perms.dcim.add_consoleport %}<li><a href="{% url 'dcim:consoleport_add' pk=device.pk %}">Console Ports</a></li>{% endif %}
-                    {% if perms.dcim.add_consoleserverport %}<li><a href="{% url 'dcim:consoleserverport_add' pk=device.pk %}">Console Server Ports</a></li>{% endif %}
-                    {% if perms.dcim.add_powerport %}<li><a href="{% url 'dcim:powerport_add' pk=device.pk %}">Power Ports</a></li>{% endif %}
-                    {% if perms.dcim.add_poweroutlet %}<li><a href="{% url 'dcim:poweroutlet_add' pk=device.pk %}">Power Outlets</a></li>{% endif %}
-                    {% if perms.dcim.add_interface %}<li><a href="{% url 'dcim:interface_add' pk=device.pk %}">Interfaces</a></li>{% endif %}
-                    {% if perms.dcim.add_frontport %}<li><a href="{% url 'dcim:frontport_add' pk=device.pk %}">Front Ports</a></li>{% endif %}
-                    {% if perms.dcim.add_rearport %}<li><a href="{% url 'dcim:rearport_add' pk=device.pk %}">Rear Ports</a></li>{% endif %}
-                    {% if perms.dcim.add_devicebay %}<li><a href="{% url 'dcim:devicebay_add' pk=device.pk %}">Device Bays</a></li>{% endif %}
+                    {% if perms.dcim.add_consoleport %}
+                        <li><a href="{% url 'dcim:consoleport_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}">Console Ports</a></li>
+                    {% endif %}
+                    {% if perms.dcim.add_consoleserverport %}
+                        <li><a href="{% url 'dcim:consoleserverport_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}">Console Server Ports</a></li>
+                    {% endif %}
+                    {% if perms.dcim.add_powerport %}
+                        <li><a href="{% url 'dcim:powerport_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}">Power Ports</a></li>
+                    {% endif %}
+                    {% if perms.dcim.add_poweroutlet %}
+                        <li><a href="{% url 'dcim:poweroutlet_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}">Power Outlets</a></li>
+                    {% endif %}
+                    {% if perms.dcim.add_interface %}
+                        <li><a href="{% url 'dcim:interface_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}">Interfaces</a></li>
+                    {% endif %}
+                    {% if perms.dcim.add_frontport %}
+                        <li><a href="{% url 'dcim:frontport_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}">Front Ports</a></li>
+                    {% endif %}
+                    {% if perms.dcim.add_rearport %}
+                        <li><a href="{% url 'dcim:rearport_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}">Rear Ports</a></li>
+                    {% endif %}
+                    {% if perms.dcim.add_devicebay %}
+                        <li><a href="{% url 'dcim:devicebay_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}">Device Bays</a></li>
+                    {% endif %}
                 </ul>
                 </ul>
             </div>
             </div>
         {% endif %}
         {% endif %}
@@ -333,12 +349,12 @@
                     {% if perms.dcim.add_interface or perms.dcim.add_consoleport or perms.dcim.add_powerport %}
                     {% if perms.dcim.add_interface or perms.dcim.add_consoleport or perms.dcim.add_powerport %}
                         <div class="panel-footer text-right noprint">
                         <div class="panel-footer text-right noprint">
                             {% if perms.dcim.add_consoleport %}
                             {% if perms.dcim.add_consoleport %}
-                                <a href="{% url 'dcim:consoleport_add' pk=device.pk %}" class="btn btn-xs btn-primary">
+                                <a href="{% url 'dcim:consoleport_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}" class="btn btn-xs btn-primary">
                                     <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add console port
                                     <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add console port
                                 </a>
                                 </a>
                             {% endif %}
                             {% endif %}
                             {% if perms.dcim.add_powerport %}
                             {% if perms.dcim.add_powerport %}
-                                <a href="{% url 'dcim:powerport_add' pk=device.pk %}" class="btn btn-xs btn-primary">
+                                <a href="{% url 'dcim:powerport_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}" class="btn btn-xs btn-primary">
                                     <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add power port
                                     <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add power port
                                 </a>
                                 </a>
                             {% endif %}
                             {% endif %}
@@ -524,13 +540,13 @@
                             </button>
                             </button>
                         {% endif %}
                         {% endif %}
                         {% if device_bays and perms.dcim.delete_devicebay %}
                         {% if device_bays and perms.dcim.delete_devicebay %}
-                            <button type="submit" formaction="{% url 'dcim:devicebay_bulk_delete' pk=device.pk  %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
+                            <button type="submit" formaction="{% url 'dcim:devicebay_bulk_delete' %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
                                 <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete selected
                                 <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete selected
                             </button>
                             </button>
                         {% endif %}
                         {% endif %}
                         {% if perms.dcim.add_devicebay %}
                         {% if perms.dcim.add_devicebay %}
                             <div class="pull-right">
                             <div class="pull-right">
-                                <a href="{% url 'dcim:devicebay_add' pk=device.pk %}" class="btn btn-primary btn-xs">
+                                <a href="{% url 'dcim:devicebay_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}" class="btn btn-primary btn-xs">
                                     <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add device bays
                                     <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add device bays
                                 </a>
                                 </a>
                             </div>
                             </div>
@@ -587,7 +603,7 @@
                             <button type="submit" name="_rename" formaction="{% url 'dcim:interface_bulk_rename' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
                             <button type="submit" name="_rename" formaction="{% url 'dcim:interface_bulk_rename' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
                                 <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
                                 <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
                             </button>
                             </button>
-                            <button type="submit" name="_edit" formaction="{% url 'dcim:interface_bulk_edit' pk=device.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
+                            <button type="submit" name="_edit" formaction="{% url 'dcim:interface_bulk_edit' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
                                 <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit
                                 <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit
                             </button>
                             </button>
                         {% endif %}
                         {% endif %}
@@ -597,13 +613,13 @@
                             </button>
                             </button>
                         {% endif %}
                         {% endif %}
                         {% if interfaces and perms.dcim.delete_interface %}
                         {% if interfaces and perms.dcim.delete_interface %}
-                            <button type="submit" name="_delete" formaction="{% url 'dcim:interface_bulk_delete' pk=device.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
+                            <button type="submit" name="_delete" formaction="{% url 'dcim:interface_bulk_delete' %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
                                 <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
                                 <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
                             </button>
                             </button>
                         {% endif %}
                         {% endif %}
                         {% if perms.dcim.add_interface %}
                         {% if perms.dcim.add_interface %}
                             <div class="pull-right">
                             <div class="pull-right">
-                                <a href="{% url 'dcim:interface_add' pk=device.pk %}" class="btn btn-primary btn-xs">
+                                <a href="{% url 'dcim:interface_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}" class="btn btn-primary btn-xs">
                                     <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add interfaces
                                     <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add interfaces
                                 </a>
                                 </a>
                             </div>
                             </div>
@@ -619,6 +635,7 @@
                 {% if perms.dcim.delete_consoleserverport %}
                 {% if perms.dcim.delete_consoleserverport %}
                     <form method="post">
                     <form method="post">
                     {% csrf_token %}
                     {% csrf_token %}
+                    <input type="hidden" name="device" value="{{ device.pk }}" />
                 {% endif %}
                 {% endif %}
                 <div class="panel panel-default">
                 <div class="panel panel-default">
                     <div class="panel-heading">
                     <div class="panel-heading">
@@ -649,7 +666,7 @@
                             <button type="submit" name="_rename" formaction="{% url 'dcim:consoleserverport_bulk_rename' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
                             <button type="submit" name="_rename" formaction="{% url 'dcim:consoleserverport_bulk_rename' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
                                 <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
                                 <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
                             </button>
                             </button>
-                            <button type="submit" name="_edit" formaction="{% url 'dcim:consoleserverport_bulk_edit' pk=device.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
+                            <button type="submit" name="_edit" formaction="{% url 'dcim:consoleserverport_bulk_edit' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
                                 <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit
                                 <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit
                             </button>
                             </button>
                             <button type="submit" name="_disconnect" formaction="{% url 'dcim:consoleserverport_bulk_disconnect' %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
                             <button type="submit" name="_disconnect" formaction="{% url 'dcim:consoleserverport_bulk_disconnect' %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
@@ -657,13 +674,13 @@
                             </button>
                             </button>
                         {% endif %}
                         {% endif %}
                         {% if consoleserverports and perms.dcim.delete_consoleserverport %}
                         {% if consoleserverports and perms.dcim.delete_consoleserverport %}
-                            <button type="submit" formaction="{% url 'dcim:consoleserverport_bulk_delete' pk=device.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
+                            <button type="submit" formaction="{% url 'dcim:consoleserverport_bulk_delete' %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
                                 <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
                                 <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
                             </button>
                             </button>
                         {% endif %}
                         {% endif %}
                         {% if perms.dcim.add_consoleserverport %}
                         {% if perms.dcim.add_consoleserverport %}
                             <div class="pull-right">
                             <div class="pull-right">
-                                <a href="{% url 'dcim:consoleserverport_add' pk=device.pk %}" class="btn btn-primary btn-xs">
+                                <a href="{% url 'dcim:consoleserverport_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}" class="btn btn-primary btn-xs">
                                     <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add console server ports
                                     <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add console server ports
                                 </a>
                                 </a>
                             </div>
                             </div>
@@ -679,6 +696,7 @@
                 {% if perms.dcim.delete_poweroutlet %}
                 {% if perms.dcim.delete_poweroutlet %}
                     <form method="post">
                     <form method="post">
                     {% csrf_token %}
                     {% csrf_token %}
+                    <input type="hidden" name="device" value="{{ device.pk }}" />
                 {% endif %}
                 {% endif %}
                 <div class="panel panel-default">
                 <div class="panel panel-default">
                     <div class="panel-heading">
                     <div class="panel-heading">
@@ -710,7 +728,7 @@
                             <button type="submit" name="_rename" formaction="{% url 'dcim:poweroutlet_bulk_rename' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
                             <button type="submit" name="_rename" formaction="{% url 'dcim:poweroutlet_bulk_rename' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
                                 <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
                                 <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
                             </button>
                             </button>
-                            <button type="submit" name="_edit" formaction="{% url 'dcim:poweroutlet_bulk_edit' pk=device.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
+                            <button type="submit" name="_edit" formaction="{% url 'dcim:poweroutlet_bulk_edit' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
                                 <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit
                                 <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit
                             </button>
                             </button>
                             <button type="submit" name="_disconnect" formaction="{% url 'dcim:poweroutlet_bulk_disconnect' %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
                             <button type="submit" name="_disconnect" formaction="{% url 'dcim:poweroutlet_bulk_disconnect' %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
@@ -718,13 +736,13 @@
                             </button>
                             </button>
                         {% endif %}
                         {% endif %}
                         {% if poweroutlets and perms.dcim.delete_poweroutlet %}
                         {% if poweroutlets and perms.dcim.delete_poweroutlet %}
-                            <button type="submit" formaction="{% url 'dcim:poweroutlet_bulk_delete' pk=device.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
+                            <button type="submit" formaction="{% url 'dcim:poweroutlet_bulk_delete' %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
                                 <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
                                 <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
                             </button>
                             </button>
                         {% endif %}
                         {% endif %}
                         {% if perms.dcim.add_poweroutlet %}
                         {% if perms.dcim.add_poweroutlet %}
                             <div class="pull-right">
                             <div class="pull-right">
-                                <a href="{% url 'dcim:poweroutlet_add' pk=device.pk %}" class="btn btn-primary btn-xs">
+                                <a href="{% url 'dcim:poweroutlet_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}" class="btn btn-primary btn-xs">
                                     <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add power outlets
                                     <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add power outlets
                                 </a>
                                 </a>
                             </div>
                             </div>
@@ -738,7 +756,8 @@
             {% endif %}
             {% endif %}
             {% if front_ports %}
             {% if front_ports %}
                 <form method="post">
                 <form method="post">
-                {% csrf_token %}
+                    {% csrf_token %}
+                    <input type="hidden" name="device" value="{{ device.pk }}" />
                     <div class="panel panel-default">
                     <div class="panel panel-default">
                         <div class="panel-heading">
                         <div class="panel-heading">
                             <strong>Front Ports</strong>
                             <strong>Front Ports</strong>
@@ -770,7 +789,7 @@
                                 <button type="submit" name="_rename" formaction="{% url 'dcim:frontport_bulk_rename' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
                                 <button type="submit" name="_rename" formaction="{% url 'dcim:frontport_bulk_rename' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
                                     <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
                                     <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
                                 </button>
                                 </button>
-                                <button type="submit" name="_edit" formaction="{% url 'dcim:frontport_bulk_edit' pk=device.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
+                                <button type="submit" name="_edit" formaction="{% url 'dcim:frontport_bulk_edit' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
                                     <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit
                                     <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit
                                 </button>
                                 </button>
                                 <button type="submit" name="_disconnect" formaction="{% url 'dcim:frontport_bulk_disconnect' %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
                                 <button type="submit" name="_disconnect" formaction="{% url 'dcim:frontport_bulk_disconnect' %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
@@ -778,13 +797,13 @@
                                 </button>
                                 </button>
                             {% endif %}
                             {% endif %}
                             {% if front_ports and perms.dcim.delete_frontport %}
                             {% if front_ports and perms.dcim.delete_frontport %}
-                                <button type="submit" formaction="{% url 'dcim:frontport_bulk_delete' pk=device.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
+                                <button type="submit" formaction="{% url 'dcim:frontport_bulk_delete' %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
                                     <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
                                     <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
                                 </button>
                                 </button>
                             {% endif %}
                             {% endif %}
                             {% if perms.dcim.add_frontport %}
                             {% if perms.dcim.add_frontport %}
                                 <div class="pull-right">
                                 <div class="pull-right">
-                                    <a href="{% url 'dcim:frontport_add' pk=device.pk %}" class="btn btn-primary btn-xs">
+                                    <a href="{% url 'dcim:frontport_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}" class="btn btn-primary btn-xs">
                                         <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add front ports
                                         <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add front ports
                                     </a>
                                     </a>
                                 </div>
                                 </div>
@@ -796,7 +815,8 @@
             {% endif %}
             {% endif %}
             {% if rear_ports %}
             {% if rear_ports %}
                 <form method="post">
                 <form method="post">
-                {% csrf_token %}
+                    {% csrf_token %}
+                    <input type="hidden" name="device" value="{{ device.pk }}" />
                     <div class="panel panel-default">
                     <div class="panel panel-default">
                         <div class="panel-heading">
                         <div class="panel-heading">
                             <strong>Rear Ports</strong>
                             <strong>Rear Ports</strong>
@@ -827,7 +847,7 @@
                                 <button type="submit" name="_rename" formaction="{% url 'dcim:rearport_bulk_rename' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
                                 <button type="submit" name="_rename" formaction="{% url 'dcim:rearport_bulk_rename' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
                                     <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
                                     <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
                                 </button>
                                 </button>
-                                <button type="submit" name="_edit" formaction="{% url 'dcim:rearport_bulk_edit' pk=device.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
+                                <button type="submit" name="_edit" formaction="{% url 'dcim:rearport_bulk_edit' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
                                     <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit
                                     <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit
                                 </button>
                                 </button>
                                 <button type="submit" name="_disconnect" formaction="{% url 'dcim:rearport_bulk_disconnect' %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
                                 <button type="submit" name="_disconnect" formaction="{% url 'dcim:rearport_bulk_disconnect' %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
@@ -835,13 +855,13 @@
                                 </button>
                                 </button>
                             {% endif %}
                             {% endif %}
                             {% if rear_ports and perms.dcim.delete_rearport %}
                             {% if rear_ports and perms.dcim.delete_rearport %}
-                                <button type="submit" formaction="{% url 'dcim:rearport_bulk_delete' pk=device.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
+                                <button type="submit" formaction="{% url 'dcim:rearport_bulk_delete' %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
                                     <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
                                     <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
                                 </button>
                                 </button>
                             {% endif %}
                             {% endif %}
                             {% if perms.dcim.add_rearport %}
                             {% if perms.dcim.add_rearport %}
                                 <div class="pull-right">
                                 <div class="pull-right">
-                                    <a href="{% url 'dcim:rearport_add' pk=device.pk %}" class="btn btn-primary btn-xs">
+                                    <a href="{% url 'dcim:rearport_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}" class="btn btn-primary btn-xs">
                                         <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add rear ports
                                         <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add rear ports
                                     </a>
                                     </a>
                                 </div>
                                 </div>

+ 3 - 6
netbox/templates/dcim/device_component_add.html

@@ -1,14 +1,11 @@
 {% extends '_base.html' %}
 {% extends '_base.html' %}
 {% load form_helpers %}
 {% load form_helpers %}
 
 
-{% block title %}Create {{ component_type }} ({{ parent }}){% endblock %}
+{% block title %}Create {{ component_type }}{% endblock %}
 
 
 {% block content %}
 {% block content %}
-<form action="." method="post" class="form form-horizontal">
+<form action="" method="post" class="form form-horizontal">
     {% csrf_token %}
     {% csrf_token %}
-    {% for field in form.hidden_fields %}
-        {{ field }}
-    {% endfor %}
     <div class="row">
     <div class="row">
         <div class="col-md-6 col-md-offset-3">
         <div class="col-md-6 col-md-offset-3">
             {% if form.non_field_errors %}
             {% if form.non_field_errors %}
@@ -27,7 +24,7 @@
                     <div class="form-group">
                     <div class="form-group">
                         <label class="col-md-3 control-label required">Device</label>
                         <label class="col-md-3 control-label required">Device</label>
                         <div class="col-md-9">
                         <div class="col-md-9">
-                            <p class="form-control-static">{{ parent }}</p>
+                            <p class="form-control-static">{{ form.device.value }}</p>
                         </div>
                         </div>
                     </div>
                     </div>
                     {% render_form form %}
                     {% render_form form %}

+ 3 - 3
netbox/templates/virtualization/virtualmachine.html

@@ -288,18 +288,18 @@
                         <button type="submit" name="_rename" formaction="{% url 'dcim:interface_bulk_rename' %}?return_url={{ virtualmachine.get_absolute_url }}" class="btn btn-warning btn-xs">
                         <button type="submit" name="_rename" formaction="{% url 'dcim:interface_bulk_rename' %}?return_url={{ virtualmachine.get_absolute_url }}" class="btn btn-warning btn-xs">
                             <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
                             <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
                         </button>
                         </button>
-                        <button type="submit" name="_edit" formaction="{% url 'virtualization:interface_bulk_edit' pk=virtualmachine.pk %}?return_url={{ virtualmachine.get_absolute_url }}" class="btn btn-warning btn-xs">
+                        <button type="submit" name="_edit" formaction="{% url 'virtualization:interface_bulk_edit' %}?return_url={{ virtualmachine.get_absolute_url }}" class="btn btn-warning btn-xs">
                             <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit
                             <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit
                         </button>
                         </button>
                     {% endif %}
                     {% endif %}
                     {% if interfaces and perms.dcim.delete_interface %}
                     {% if interfaces and perms.dcim.delete_interface %}
-                        <button type="submit" name="_delete" formaction="{% url 'virtualization:interface_bulk_delete' pk=virtualmachine.pk %}?return_url={{ virtualmachine.get_absolute_url }}" class="btn btn-danger btn-xs">
+                        <button type="submit" name="_delete" formaction="{% url 'virtualization:interface_bulk_delete' %}?return_url={{ virtualmachine.get_absolute_url }}" class="btn btn-danger btn-xs">
                             <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
                             <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
                         </button>
                         </button>
                     {% endif %}
                     {% endif %}
                     {% if perms.dcim.add_interface %}
                     {% if perms.dcim.add_interface %}
                         <div class="pull-right">
                         <div class="pull-right">
-                            <a href="{% url 'virtualization:interface_add' pk=virtualmachine.pk %}" class="btn btn-primary btn-xs">
+                            <a href="{% url 'virtualization:interface_add' %}?virtual_machine={{ virtualmachine.pk }}&return_url={{ virtualmachine.get_absolute_url }}" class="btn btn-primary btn-xs">
                                 <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add interfaces
                                 <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add interfaces
                             </a>
                             </a>
                         </div>
                         </div>

+ 1 - 1
netbox/templates/virtualization/virtualmachine_component_add.html

@@ -5,7 +5,7 @@
 {% block title %}Create {{ component_type }} ({{ parent }}){% endblock %}
 {% block title %}Create {{ component_type }} ({{ parent }}){% endblock %}
 
 
 {% block content %}
 {% block content %}
-<form action="." method="post" class="form form-horizontal">
+<form action="" method="post" class="form form-horizontal">
     {% csrf_token %}
     {% csrf_token %}
     <div class="row">
     <div class="row">
         <div class="col-md-6 col-md-offset-3">
         <div class="col-md-6 col-md-offset-3">

+ 2 - 6
netbox/utilities/forms.py

@@ -727,14 +727,11 @@ class ConfirmationForm(BootstrapMixin, ReturnURLForm):
     confirm = forms.BooleanField(required=True, widget=forms.HiddenInput(), initial=True)
     confirm = forms.BooleanField(required=True, widget=forms.HiddenInput(), initial=True)
 
 
 
 
+# TODO: Remove ComponentForm
 class ComponentForm(BootstrapMixin, forms.Form):
 class ComponentForm(BootstrapMixin, forms.Form):
     """
     """
     Allow inclusion of the parent Device/VirtualMachine as context for limiting field choices.
     Allow inclusion of the parent Device/VirtualMachine as context for limiting field choices.
     """
     """
-    def __init__(self, parent, *args, **kwargs):
-        self.parent = parent
-        super().__init__(*args, **kwargs)
-
     def get_iterative_data(self, iteration):
     def get_iterative_data(self, iteration):
         return {}
         return {}
 
 
@@ -743,10 +740,9 @@ class BulkEditForm(forms.Form):
     """
     """
     Base form for editing multiple objects in bulk
     Base form for editing multiple objects in bulk
     """
     """
-    def __init__(self, model, parent_obj=None, *args, **kwargs):
+    def __init__(self, model, *args, **kwargs):
         super().__init__(*args, **kwargs)
         super().__init__(*args, **kwargs)
         self.model = model
         self.model = model
-        self.parent_obj = parent_obj
         self.nullable_fields = []
         self.nullable_fields = []
 
 
         # Copy any nullable fields defined in Meta
         # Copy any nullable fields defined in Meta

+ 85 - 14
netbox/utilities/testing/testcases.py

@@ -1,11 +1,12 @@
 from django.contrib.auth.models import Permission, User
 from django.contrib.auth.models import Permission, User
 from django.core.exceptions import ObjectDoesNotExist
 from django.core.exceptions import ObjectDoesNotExist
+from django.forms.models import model_to_dict
 from django.test import Client, TestCase as _TestCase, override_settings
 from django.test import Client, TestCase as _TestCase, override_settings
 from django.urls import reverse, NoReverseMatch
 from django.urls import reverse, NoReverseMatch
 from rest_framework.test import APIClient
 from rest_framework.test import APIClient
 
 
 from users.models import Token
 from users.models import Token
-from .utils import disable_warnings, model_to_dict, post_data
+from .utils import disable_warnings, post_data
 
 
 
 
 class TestCase(_TestCase):
 class TestCase(_TestCase):
@@ -56,6 +57,30 @@ class TestCase(_TestCase):
             expected_status, response.status_code, getattr(response, 'data', 'No data')
             expected_status, response.status_code, getattr(response, 'data', 'No data')
         ))
         ))
 
 
+    def assertInstanceEqual(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):
 class APITestCase(TestCase):
     client_class = APIClient
     client_class = APIClient
@@ -92,6 +117,9 @@ class StandardTestCases:
         # CSV lines used for bulk import of new objects
         # CSV lines used for bulk import of new objects
         csv_data = ()
         csv_data = ()
 
 
+        # Form data used when creating multiple objects
+        bulk_create_data = {}
+
         # Form data to be used when editing multiple objects at once
         # Form data to be used when editing multiple objects at once
         bulk_edit_data = {}
         bulk_edit_data = {}
 
 
@@ -104,16 +132,27 @@ class StandardTestCases:
             if self.model is None:
             if self.model is None:
                 raise Exception("Test case requires model to be defined")
                 raise Exception("Test case requires model to be defined")
 
 
-        def _get_url(self, action, instance=None):
+        #
+        # URL functions
+        #
+
+        def _get_base_url(self):
             """
             """
-            Return the URL name for a specific action. An instance must be specified for
-            get/edit/delete views.
+            Return the base format for a URL for the test's model. Override this to test for a model which belongs
+            to a different app (e.g. testing Interfaces within the virtualization app).
             """
             """
-            url_format = '{}:{}_{{}}'.format(
+            return '{}:{}_{{}}'.format(
                 self.model._meta.app_label,
                 self.model._meta.app_label,
                 self.model._meta.model_name
                 self.model._meta.model_name
             )
             )
 
 
+        def _get_url(self, action, instance=None):
+            """
+            Return the URL name for a specific action. An instance must be specified for
+            get/edit/delete views.
+            """
+            url_format = self._get_base_url()
+
             if action in ('list', 'add', 'import', 'bulk_edit', 'bulk_delete'):
             if action in ('list', 'add', 'import', 'bulk_edit', 'bulk_delete'):
                 return reverse(url_format.format(action))
                 return reverse(url_format.format(action))
 
 
@@ -131,6 +170,13 @@ class StandardTestCases:
             else:
             else:
                 raise Exception("Invalid action for URL resolution: {}".format(action))
                 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=[])
         @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
         def test_list_objects(self):
         def test_list_objects(self):
             # Attempt to make the request without required permissions
             # Attempt to make the request without required permissions
@@ -187,7 +233,7 @@ class StandardTestCases:
 
 
             self.assertEqual(initial_count + 1, self.model.objects.count())
             self.assertEqual(initial_count + 1, self.model.objects.count())
             instance = self.model.objects.order_by('-pk').first()
             instance = self.model.objects.order_by('-pk').first()
-            self.assertDictEqual(model_to_dict(instance), self.form_data)
+            self.assertInstanceEqual(instance, self.form_data)
 
 
         @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
         @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
         def test_edit_object(self):
         def test_edit_object(self):
@@ -211,7 +257,7 @@ class StandardTestCases:
             self.assertHttpStatus(response, 302)
             self.assertHttpStatus(response, 302)
 
 
             instance = self.model.objects.get(pk=instance.pk)
             instance = self.model.objects.get(pk=instance.pk)
-            self.assertDictEqual(model_to_dict(instance), self.form_data)
+            self.assertInstanceEqual(instance, self.form_data)
 
 
         @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
         @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
         def test_delete_object(self):
         def test_delete_object(self):
@@ -263,7 +309,8 @@ class StandardTestCases:
 
 
         @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
         @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
         def test_bulk_edit_objects(self):
         def test_bulk_edit_objects(self):
-            pk_list = self.model.objects.values_list('pk', flat=True)
+            # Bulk edit the first three objects only
+            pk_list = self.model.objects.values_list('pk', flat=True)[:3]
 
 
             request = {
             request = {
                 'path': self._get_url('bulk_edit'),
                 'path': self._get_url('bulk_edit'),
@@ -288,13 +335,8 @@ class StandardTestCases:
             response = self.client.post(**request)
             response = self.client.post(**request)
             self.assertHttpStatus(response, 302)
             self.assertHttpStatus(response, 302)
 
 
-            bulk_edit_fields = self.bulk_edit_data.keys()
             for i, instance in enumerate(self.model.objects.filter(pk__in=pk_list)):
             for i, instance in enumerate(self.model.objects.filter(pk__in=pk_list)):
-                self.assertDictEqual(
-                    model_to_dict(instance, fields=bulk_edit_fields),
-                    self.bulk_edit_data,
-                    msg="Instance {} failed to validate after bulk edit: {}".format(i, instance)
-                )
+                self.assertInstanceEqual(instance, self.bulk_edit_data)
 
 
         @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
         @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
         def test_bulk_delete_objects(self):
         def test_bulk_delete_objects(self):
@@ -323,3 +365,32 @@ class StandardTestCases:
 
 
             # Check that all objects were deleted
             # Check that all objects were deleted
             self.assertEqual(self.model.objects.count(), 0)
             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.assertInstanceEqual(instance, self.bulk_create_data)

+ 0 - 29
netbox/utilities/testing/utils.py

@@ -2,35 +2,6 @@ import logging
 from contextlib import contextmanager
 from contextlib import contextmanager
 
 
 from django.contrib.auth.models import Permission, User
 from django.contrib.auth.models import Permission, User
-from django.forms.models import model_to_dict as _model_to_dict
-
-
-def model_to_dict(instance, fields=None, exclude=None):
-    """
-    Customized wrapper for Django's built-in model_to_dict(). Does the following:
-      - Excludes the instance ID field
-      - Exclude any fields prepended with an underscore
-      - Convert any assigned tags to a comma-separated string
-    """
-    _exclude = ['id']
-    if exclude is not None:
-        _exclude += exclude
-
-    model_dict = _model_to_dict(instance, fields=fields, exclude=_exclude)
-
-    for key in list(model_dict.keys()):
-        if key.startswith('_'):
-            del model_dict[key]
-
-        # TODO: Differentiate between tags assigned to the instance and a M2M field for tags (ex: ConfigContext)
-        elif 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]]
-
-    return model_dict
 
 
 
 
 def post_data(data):
 def post_data(data):

+ 13 - 0
netbox/utilities/utils.py

@@ -4,6 +4,7 @@ from collections import OrderedDict
 
 
 from django.core.serializers import serialize
 from django.core.serializers import serialize
 from django.db.models import Count, OuterRef, Subquery
 from django.db.models import Count, OuterRef, Subquery
+from django.http import QueryDict
 from jinja2 import Environment
 from jinja2 import Environment
 
 
 from dcim.choices import CableLengthUnitChoices
 from dcim.choices import CableLengthUnitChoices
@@ -209,3 +210,15 @@ def prepare_cloned_fields(instance):
     )
     )
 
 
     return param_string
     return param_string
+
+
+def querydict_to_dict(querydict):
+    """
+    Convert a django.http.QueryDict object to a regular Python dictionary, preserving lists of multiple values.
+    (QueryDict.dict() will return only the last value in a list for each key.)
+    """
+    assert isinstance(querydict, QueryDict)
+    return {
+        key: querydict.get(key) if len(value) == 1 else querydict.getlist(key)
+        for key, value in querydict.lists()
+    }

+ 28 - 47
netbox/utilities/views.py

@@ -25,7 +25,7 @@ from extras.models import CustomField, CustomFieldValue, ExportTemplate
 from extras.querysets import CustomFieldQueryset
 from extras.querysets import CustomFieldQueryset
 from utilities.exceptions import AbortTransaction
 from utilities.exceptions import AbortTransaction
 from utilities.forms import BootstrapMixin, CSVDataField
 from utilities.forms import BootstrapMixin, CSVDataField
-from utilities.utils import csv_format, prepare_cloned_fields
+from utilities.utils import csv_format, prepare_cloned_fields, querydict_to_dict
 from .error_handlers import handle_protectederror
 from .error_handlers import handle_protectederror
 from .forms import ConfirmationForm, ImportForm
 from .forms import ConfirmationForm, ImportForm
 from .paginator import EnhancedPaginator
 from .paginator import EnhancedPaginator
@@ -604,14 +604,12 @@ class BulkEditView(GetReturnURLMixin, View):
     Edit objects in bulk.
     Edit objects in bulk.
 
 
     queryset: Custom queryset to use when retrieving objects (e.g. to select related objects)
     queryset: Custom queryset to use when retrieving objects (e.g. to select related objects)
-    parent_model: The model of the parent object (if any)
     filter: FilterSet to apply when deleting by QuerySet
     filter: FilterSet to apply when deleting by QuerySet
     table: The table used to display devices being edited
     table: The table used to display devices being edited
     form: The form class used to edit objects in bulk
     form: The form class used to edit objects in bulk
     template_name: The name of the template
     template_name: The name of the template
     """
     """
     queryset = None
     queryset = None
-    parent_model = None
     filterset = None
     filterset = None
     table = None
     table = None
     form = None
     form = None
@@ -624,20 +622,15 @@ class BulkEditView(GetReturnURLMixin, View):
 
 
         model = self.queryset.model
         model = self.queryset.model
 
 
-        # Attempt to derive parent object if a parent class has been given
-        if self.parent_model:
-            parent_obj = get_object_or_404(self.parent_model, **kwargs)
-        else:
-            parent_obj = None
+        # Create a mutable copy of the POST data
+        post_data = request.POST.copy()
 
 
-        # Are we editing *all* objects in the queryset or just a selected subset?
-        if request.POST.get('_all') and self.filterset is not None:
-            pk_list = [obj.pk for obj in self.filterset(request.GET, model.objects.only('pk')).qs]
-        else:
-            pk_list = [int(pk) for pk in request.POST.getlist('pk')]
+        # If we are editing *all* objects in the queryset, replace the PK list with all matched objects.
+        if post_data.get('_all') and self.filterset is not None:
+            post_data['pk'] = [obj.pk for obj in self.filterset(request.GET, model.objects.only('pk')).qs]
 
 
         if '_apply' in request.POST:
         if '_apply' in request.POST:
-            form = self.form(model, parent_obj, request.POST)
+            form = self.form(model, request.POST, initial=request.GET)
             if form.is_valid():
             if form.is_valid():
 
 
                 custom_fields = form.custom_fields if hasattr(form, 'custom_fields') else []
                 custom_fields = form.custom_fields if hasattr(form, 'custom_fields') else []
@@ -651,7 +644,7 @@ class BulkEditView(GetReturnURLMixin, View):
                     with transaction.atomic():
                     with transaction.atomic():
 
 
                         updated_count = 0
                         updated_count = 0
-                        for obj in model.objects.filter(pk__in=pk_list):
+                        for obj in model.objects.filter(pk__in=form.cleaned_data['pk']):
 
 
                             # Update standard fields. If a field is listed in _nullify, delete its value.
                             # Update standard fields. If a field is listed in _nullify, delete its value.
                             for name in standard_fields:
                             for name in standard_fields:
@@ -719,12 +712,16 @@ class BulkEditView(GetReturnURLMixin, View):
                     messages.error(self.request, "{} failed validation: {}".format(obj, e))
                     messages.error(self.request, "{} failed validation: {}".format(obj, e))
 
 
         else:
         else:
-            initial_data = request.POST.copy()
-            initial_data['pk'] = pk_list
-            form = self.form(model, parent_obj, initial=initial_data)
+            # Pass the PK list as initial data to avoid binding the form
+            initial_data = querydict_to_dict(post_data)
+
+            # Append any normal initial data (passed as GET parameters)
+            initial_data.update(request.GET)
+
+            form = self.form(model, initial=initial_data)
 
 
         # Retrieve objects being edited
         # Retrieve objects being edited
-        table = self.table(self.queryset.filter(pk__in=pk_list), orderable=False)
+        table = self.table(self.queryset.filter(pk__in=post_data.getlist('pk')), orderable=False)
         if not table.rows:
         if not table.rows:
             messages.warning(request, "No {} were selected.".format(model._meta.verbose_name_plural))
             messages.warning(request, "No {} were selected.".format(model._meta.verbose_name_plural))
             return redirect(self.get_return_url(request))
             return redirect(self.get_return_url(request))
@@ -742,14 +739,12 @@ class BulkDeleteView(GetReturnURLMixin, View):
     Delete objects in bulk.
     Delete objects in bulk.
 
 
     queryset: Custom queryset to use when retrieving objects (e.g. to select related objects)
     queryset: Custom queryset to use when retrieving objects (e.g. to select related objects)
-    parent_model: The model of the parent object (if any)
     filter: FilterSet to apply when deleting by QuerySet
     filter: FilterSet to apply when deleting by QuerySet
     table: The table used to display devices being deleted
     table: The table used to display devices being deleted
     form: The form class used to delete objects in bulk
     form: The form class used to delete objects in bulk
     template_name: The name of the template
     template_name: The name of the template
     """
     """
     queryset = None
     queryset = None
-    parent_model = None
     filterset = None
     filterset = None
     table = None
     table = None
     form = None
     form = None
@@ -762,12 +757,6 @@ class BulkDeleteView(GetReturnURLMixin, View):
 
 
         model = self.queryset.model
         model = self.queryset.model
 
 
-        # Attempt to derive parent object if a parent class has been given
-        if self.parent_model:
-            parent_obj = get_object_or_404(self.parent_model, **kwargs)
-        else:
-            parent_obj = None
-
         # Are we deleting *all* objects in the queryset or just a selected subset?
         # Are we deleting *all* objects in the queryset or just a selected subset?
         if request.POST.get('_all'):
         if request.POST.get('_all'):
             if self.filterset is not None:
             if self.filterset is not None:
@@ -809,7 +798,6 @@ class BulkDeleteView(GetReturnURLMixin, View):
 
 
         return render(request, self.template_name, {
         return render(request, self.template_name, {
             'form': form,
             'form': form,
-            'parent_obj': parent_obj,
             'obj_type_plural': model._meta.verbose_name_plural,
             'obj_type_plural': model._meta.verbose_name_plural,
             'table': table,
             'table': table,
             'return_url': self.get_return_url(request),
             'return_url': self.get_return_url(request),
@@ -832,7 +820,8 @@ class BulkDeleteView(GetReturnURLMixin, View):
 # Device/VirtualMachine components
 # Device/VirtualMachine components
 #
 #
 
 
-class ComponentCreateView(View):
+# TODO: Replace with BulkCreateView
+class ComponentCreateView(GetReturnURLMixin, View):
     """
     """
     Add one or more components (e.g. interfaces, console ports, etc.) to a Device or VirtualMachine.
     Add one or more components (e.g. interfaces, console ports, etc.) to a Device or VirtualMachine.
     """
     """
@@ -843,30 +832,23 @@ class ComponentCreateView(View):
     model_form = None
     model_form = None
     template_name = None
     template_name = None
 
 
-    def get(self, request, pk):
+    def get(self, request):
 
 
-        parent = get_object_or_404(self.parent_model, pk=pk)
-        data = deepcopy(request.GET)
-        data[self.parent_field] = parent.pk
-        form = self.form(parent, initial=data)
+        form = self.form(initial=request.GET)
 
 
         return render(request, self.template_name, {
         return render(request, self.template_name, {
-            'parent': parent,
             'component_type': self.model._meta.verbose_name,
             'component_type': self.model._meta.verbose_name,
             'form': form,
             'form': form,
-            'return_url': parent.get_absolute_url(),
+            'return_url': self.get_return_url(request),
         })
         })
 
 
-    def post(self, request, pk):
-
-        parent = get_object_or_404(self.parent_model, pk=pk)
+    def post(self, request):
 
 
-        form = self.form(parent, request.POST)
+        form = self.form(request.POST, initial=request.GET)
         if form.is_valid():
         if form.is_valid():
 
 
             new_components = []
             new_components = []
             data = deepcopy(request.POST)
             data = deepcopy(request.POST)
-            data[self.parent_field] = parent.pk
 
 
             for i, name in enumerate(form.cleaned_data['name_pattern']):
             for i, name in enumerate(form.cleaned_data['name_pattern']):
 
 
@@ -891,19 +873,18 @@ class ComponentCreateView(View):
                 for component_form in new_components:
                 for component_form in new_components:
                     component_form.save()
                     component_form.save()
 
 
-                messages.success(request, "Added {} {} to {}.".format(
-                    len(new_components), self.model._meta.verbose_name_plural, parent
+                messages.success(request, "Added {} {}".format(
+                    len(new_components), self.model._meta.verbose_name_plural
                 ))
                 ))
                 if '_addanother' in request.POST:
                 if '_addanother' in request.POST:
-                    return redirect(request.path)
+                    return redirect(request.get_full_path())
                 else:
                 else:
-                    return redirect(parent.get_absolute_url())
+                    return redirect(self.get_return_url(request))
 
 
         return render(request, self.template_name, {
         return render(request, self.template_name, {
-            'parent': parent,
             'component_type': self.model._meta.verbose_name,
             'component_type': self.model._meta.verbose_name,
             'form': form,
             'form': form,
-            'return_url': parent.get_absolute_url(),
+            'return_url': self.get_return_url(request),
         })
         })
 
 
 
 

+ 47 - 35
netbox/virtualization/forms.py

@@ -739,6 +739,10 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm):
 
 
 
 
 class InterfaceCreateForm(ComponentForm):
 class InterfaceCreateForm(ComponentForm):
+    virtual_machine = forms.ModelChoiceField(
+        queryset=VirtualMachine.objects.all(),
+        widget=forms.HiddenInput()
+    )
     name_pattern = ExpandableNameField(
     name_pattern = ExpandableNameField(
         label='Name'
         label='Name'
     )
     )
@@ -748,7 +752,8 @@ class InterfaceCreateForm(ComponentForm):
         widget=forms.HiddenInput()
         widget=forms.HiddenInput()
     )
     )
     enabled = forms.BooleanField(
     enabled = forms.BooleanField(
-        required=False
+        required=False,
+        initial=True
     )
     )
     mtu = forms.IntegerField(
     mtu = forms.IntegerField(
         required=False,
         required=False,
@@ -792,14 +797,13 @@ class InterfaceCreateForm(ComponentForm):
     )
     )
 
 
     def __init__(self, *args, **kwargs):
     def __init__(self, *args, **kwargs):
-
-        # Set interfaces enabled by default
-        kwargs['initial'] = kwargs.get('initial', {}).copy()
-        kwargs['initial'].update({'enabled': True})
-
         super().__init__(*args, **kwargs)
         super().__init__(*args, **kwargs)
 
 
-        # Limit VLan choices to those in: global vlans, global groups, the current site's group, the current site
+        virtual_machine = VirtualMachine.objects.get(
+            pk=self.initial.get('virtual_machine') or self.data.get('virtual_machine')
+        )
+
+        # Limit VLAN choices to those in: global vlans, global groups, the current site's group, the current site
         vlan_choices = []
         vlan_choices = []
         global_vlans = VLAN.objects.filter(site=None, group=None)
         global_vlans = VLAN.objects.filter(site=None, group=None)
         vlan_choices.append(
         vlan_choices.append(
@@ -811,7 +815,7 @@ class InterfaceCreateForm(ComponentForm):
                 (group.name, [(vlan.pk, vlan) for vlan in global_group_vlans])
                 (group.name, [(vlan.pk, vlan) for vlan in global_group_vlans])
             )
             )
 
 
-        site = getattr(self.parent.cluster, 'site', None)
+        site = getattr(virtual_machine.cluster, 'site', None)
         if site is not None:
         if site is not None:
 
 
             # Add non-grouped site VLANs
             # Add non-grouped site VLANs
@@ -835,6 +839,10 @@ class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm):
         queryset=Interface.objects.all(),
         queryset=Interface.objects.all(),
         widget=forms.MultipleHiddenInput()
         widget=forms.MultipleHiddenInput()
     )
     )
+    virtual_machine = forms.ModelChoiceField(
+        queryset=VirtualMachine.objects.all(),
+        widget=forms.HiddenInput()
+    )
     enabled = forms.NullBooleanField(
     enabled = forms.NullBooleanField(
         required=False,
         required=False,
         widget=BulkEditNullBooleanSelect()
         widget=BulkEditNullBooleanSelect()
@@ -881,35 +889,39 @@ class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm):
     def __init__(self, *args, **kwargs):
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
         super().__init__(*args, **kwargs)
 
 
-        # Limit VLan choices to those in: global vlans, global groups, the current site's group, the current site
-        vlan_choices = []
-        global_vlans = VLAN.objects.filter(site=None, group=None)
-        vlan_choices.append(
-            ('Global', [(vlan.pk, vlan) for vlan in global_vlans])
-        )
-        for group in VLANGroup.objects.filter(site=None):
-            global_group_vlans = VLAN.objects.filter(group=group)
+        # Limit available VLANs based on the parent VirtualMachine
+        if 'virtual_machine' in self.initial:
+            parent_obj = VirtualMachine.objects.filter(pk=self.initial['virtual_machine']).first()
+
+            # Limit VLAN choices to global VLANs, VLANs in global groups, the current site's group, the current site
+            vlan_choices = []
+            global_vlans = VLAN.objects.filter(site=None, group=None)
             vlan_choices.append(
             vlan_choices.append(
-                (group.name, [(vlan.pk, vlan) for vlan in global_group_vlans])
+                ('Global', [(vlan.pk, vlan) for vlan in global_vlans])
             )
             )
-        if self.parent_obj.cluster is not None:
-            site = getattr(self.parent_obj.cluster, 'site', None)
-            if site is not None:
-
-                # Add non-grouped site VLANs
-                site_vlans = VLAN.objects.filter(site=site, group=None)
-                vlan_choices.append((site.name, [(vlan.pk, vlan) for vlan in site_vlans]))
-
-                # Add grouped site VLANs
-                for group in VLANGroup.objects.filter(site=site):
-                    site_group_vlans = VLAN.objects.filter(group=group)
-                    vlan_choices.append((
-                        '{} / {}'.format(group.site.name, group.name),
-                        [(vlan.pk, vlan) for vlan in site_group_vlans]
-                    ))
-
-        self.fields['untagged_vlan'].choices = [(None, '---------')] + vlan_choices
-        self.fields['tagged_vlans'].choices = vlan_choices
+            for group in VLANGroup.objects.filter(site=None):
+                global_group_vlans = VLAN.objects.filter(group=group)
+                vlan_choices.append(
+                    (group.name, [(vlan.pk, vlan) for vlan in global_group_vlans])
+                )
+            if parent_obj.cluster is not None:
+                site = getattr(parent_obj.cluster, 'site', None)
+                if site is not None:
+
+                    # Add non-grouped site VLANs
+                    site_vlans = VLAN.objects.filter(site=site, group=None)
+                    vlan_choices.append((site.name, [(vlan.pk, vlan) for vlan in site_vlans]))
+
+                    # Add grouped site VLANs
+                    for group in VLANGroup.objects.filter(site=site):
+                        site_group_vlans = VLAN.objects.filter(group=group)
+                        vlan_choices.append((
+                            '{} / {}'.format(group.site.name, group.name),
+                            [(vlan.pk, vlan) for vlan in site_group_vlans]
+                        ))
+
+            self.fields['untagged_vlan'].choices = [(None, '---------')] + vlan_choices
+            self.fields['tagged_vlans'].choices = vlan_choices
 
 
 
 
 #
 #

+ 95 - 1
netbox/virtualization/tests/test_views.py

@@ -1,4 +1,8 @@
-from dcim.models import DeviceRole, Platform, Site
+from netaddr import EUI
+
+from dcim.choices import InterfaceModeChoices
+from dcim.models import DeviceRole, Interface, Platform, Site
+from ipam.models import VLAN
 from utilities.testing import StandardTestCases
 from utilities.testing import StandardTestCases
 from virtualization.choices import *
 from virtualization.choices import *
 from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
 from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
@@ -187,3 +191,93 @@ class VirtualMachineTestCase(StandardTestCases.Views):
             'disk': 8000,
             'disk': 8000,
             'comments': 'New comments',
             'comments': 'New comments',
         }
         }
+
+
+class InterfaceTestCase(StandardTestCases.Views):
+    model = Interface
+
+    # Disable inapplicable tests
+    test_list_objects = None
+    test_create_object = None
+    test_import_objects = 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
+        return 'virtualization:interface_{}'
+
+    @classmethod
+    def setUpTestData(cls):
+
+        site = Site.objects.create(name='Site 1', slug='site-1')
+        devicerole = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
+        clustertype = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1')
+        cluster = Cluster.objects.create(name='Cluster 1', type=clustertype, site=site)
+        virtualmachines = (
+            VirtualMachine(name='Virtual Machine 1', cluster=cluster, role=devicerole),
+            VirtualMachine(name='Virtual Machine 2', cluster=cluster, role=devicerole),
+        )
+        VirtualMachine.objects.bulk_create(virtualmachines)
+
+        Interface.objects.bulk_create([
+            Interface(virtual_machine=virtualmachines[0], name='Interface 1', type=InterfaceTypeChoices.TYPE_VIRTUAL),
+            Interface(virtual_machine=virtualmachines[0], name='Interface 2', type=InterfaceTypeChoices.TYPE_VIRTUAL),
+            Interface(virtual_machine=virtualmachines[0], name='Interface 3', type=InterfaceTypeChoices.TYPE_VIRTUAL),
+        ])
+
+        vlans = (
+            VLAN(vid=1, name='VLAN1', site=site),
+            VLAN(vid=101, name='VLAN101', site=site),
+            VLAN(vid=102, name='VLAN102', site=site),
+            VLAN(vid=103, name='VLAN103', site=site),
+        )
+        VLAN.objects.bulk_create(vlans)
+
+        cls.form_data = {
+            'virtual_machine': virtualmachines[1].pk,
+            'name': 'Interface X',
+            '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_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,
+            'mtu': 2000,
+            'description': 'New description',
+            'mode': InterfaceModeChoices.MODE_TAGGED,
+            # 'untagged_vlan': vlans[0].pk,
+            # 'tagged_vlans': [v.pk for v in vlans[1:4]],
+        }
+
+        cls.csv_data = (
+            "device,name,type",
+            "Device 1,Interface 4,1000BASE-T (1GE)",
+            "Device 1,Interface 5,1000BASE-T (1GE)",
+            "Device 1,Interface 6,1000BASE-T (1GE)",
+        )

+ 4 - 3
netbox/virtualization/urls.py

@@ -52,9 +52,10 @@ urlpatterns = [
 
 
     # VM interfaces
     # VM interfaces
     path(r'virtual-machines/interfaces/add/', views.VirtualMachineBulkAddInterfaceView.as_view(), name='virtualmachine_bulk_add_interface'),
     path(r'virtual-machines/interfaces/add/', views.VirtualMachineBulkAddInterfaceView.as_view(), name='virtualmachine_bulk_add_interface'),
-    path(r'virtual-machines/<int:pk>/interfaces/add/', views.InterfaceCreateView.as_view(), name='interface_add'),
-    path(r'virtual-machines/<int:pk>/interfaces/edit/', views.InterfaceBulkEditView.as_view(), name='interface_bulk_edit'),
-    path(r'virtual-machines/<int:pk>/interfaces/delete/', views.InterfaceBulkDeleteView.as_view(), name='interface_bulk_delete'),
+    path(r'interfaces/add/', views.InterfaceCreateView.as_view(), name='interface_add'),
+    path(r'interfaces/edit/', views.InterfaceBulkEditView.as_view(), name='interface_bulk_edit'),
+    path(r'interfaces/delete/', views.InterfaceBulkDeleteView.as_view(), name='interface_bulk_delete'),
+    # TODO: Rename vm-interfaces to interfaces
     path(r'vm-interfaces/<int:pk>/edit/', views.InterfaceEditView.as_view(), name='interface_edit'),
     path(r'vm-interfaces/<int:pk>/edit/', views.InterfaceEditView.as_view(), name='interface_edit'),
     path(r'vm-interfaces/<int:pk>/delete/', views.InterfaceDeleteView.as_view(), name='interface_delete'),
     path(r'vm-interfaces/<int:pk>/delete/', views.InterfaceDeleteView.as_view(), name='interface_delete'),
 
 

+ 0 - 4
netbox/virtualization/views.py

@@ -330,8 +330,6 @@ class VirtualMachineBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 
 
 class InterfaceCreateView(PermissionRequiredMixin, ComponentCreateView):
 class InterfaceCreateView(PermissionRequiredMixin, ComponentCreateView):
     permission_required = 'dcim.add_interface'
     permission_required = 'dcim.add_interface'
-    parent_model = VirtualMachine
-    parent_field = 'virtual_machine'
     model = Interface
     model = Interface
     form = forms.InterfaceCreateForm
     form = forms.InterfaceCreateForm
     model_form = forms.InterfaceForm
     model_form = forms.InterfaceForm
@@ -353,7 +351,6 @@ class InterfaceDeleteView(PermissionRequiredMixin, ObjectDeleteView):
 class InterfaceBulkEditView(PermissionRequiredMixin, BulkEditView):
 class InterfaceBulkEditView(PermissionRequiredMixin, BulkEditView):
     permission_required = 'dcim.change_interface'
     permission_required = 'dcim.change_interface'
     queryset = Interface.objects.all()
     queryset = Interface.objects.all()
-    parent_model = VirtualMachine
     table = tables.InterfaceTable
     table = tables.InterfaceTable
     form = forms.InterfaceBulkEditForm
     form = forms.InterfaceBulkEditForm
 
 
@@ -361,7 +358,6 @@ class InterfaceBulkEditView(PermissionRequiredMixin, BulkEditView):
 class InterfaceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class InterfaceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'dcim.delete_interface'
     permission_required = 'dcim.delete_interface'
     queryset = Interface.objects.all()
     queryset = Interface.objects.all()
-    parent_model = VirtualMachine
     table = tables.InterfaceTable
     table = tables.InterfaceTable