Jelajahi Sumber

Merge pull request #4080 from netbox-community/4077-view-tests

Closes #4077: Add tests for bulk edit/delete views
Jeremy Stretch 6 tahun lalu
induk
melakukan
91929aae1b

+ 37 - 10
netbox/circuits/tests/test_views.py

@@ -36,6 +36,15 @@ class ProviderTestCase(StandardTestCases.Views):
             "Provider 6,provider-6",
         )
 
+        cls.bulk_edit_data = {
+            'asn': 65009,
+            'account': '5678',
+            'portal_url': 'http://example.com/portal2',
+            'noc_contact': 'noc2@example.com',
+            'admin_contact': 'admin2@example.com',
+            'comments': 'New comments',
+        }
+
 
 class CircuitTypeTestCase(StandardTestCases.Views):
     model = CircuitType
@@ -43,6 +52,7 @@ class CircuitTypeTestCase(StandardTestCases.Views):
     # Disable inapplicable tests
     test_get_object = None
     test_delete_object = None
+    test_bulk_edit_objects = None
 
     @classmethod
     def setUpTestData(cls):
@@ -73,23 +83,29 @@ class CircuitTestCase(StandardTestCases.Views):
     @classmethod
     def setUpTestData(cls):
 
-        provider = Provider(name='Provider 1', slug='provider-1', asn=65001)
-        provider.save()
+        providers = (
+            Provider(name='Provider 1', slug='provider-1', asn=65001),
+            Provider(name='Provider 2', slug='provider-2', asn=65002),
+        )
+        Provider.objects.bulk_create(providers)
 
-        circuittype = CircuitType(name='Circuit Type 1', slug='circuit-type-1')
-        circuittype.save()
+        circuittypes = (
+            CircuitType(name='Circuit Type 1', slug='circuit-type-1'),
+            CircuitType(name='Circuit Type 2', slug='circuit-type-2'),
+        )
+        CircuitType.objects.bulk_create(circuittypes)
 
         Circuit.objects.bulk_create([
-            Circuit(cid='Circuit 1', provider=provider, type=circuittype),
-            Circuit(cid='Circuit 2', provider=provider, type=circuittype),
-            Circuit(cid='Circuit 3', provider=provider, type=circuittype),
+            Circuit(cid='Circuit 1', provider=providers[0], type=circuittypes[0]),
+            Circuit(cid='Circuit 2', provider=providers[0], type=circuittypes[0]),
+            Circuit(cid='Circuit 3', provider=providers[0], type=circuittypes[0]),
         ])
 
         cls.form_data = {
             'cid': 'Circuit X',
-            'provider': provider.pk,
-            'type': circuittype.pk,
-            'status': CircuitStatusChoices.STATUS_ACTIVE,
+            'provider': providers[1].pk,
+            'type': circuittypes[1].pk,
+            'status': CircuitStatusChoices.STATUS_DECOMMISSIONED,
             'tenant': None,
             'install_date': datetime.date(2020, 1, 1),
             'commit_rate': 1000,
@@ -104,3 +120,14 @@ class CircuitTestCase(StandardTestCases.Views):
             "Circuit 5,Provider 1,Circuit Type 1",
             "Circuit 6,Provider 1,Circuit Type 1",
         )
+
+        cls.bulk_edit_data = {
+            'provider': providers[1].pk,
+            'type': circuittypes[1].pk,
+            'status': CircuitStatusChoices.STATUS_DECOMMISSIONED,
+            'tenant': None,
+            'commit_rate': 2000,
+            'description': 'New description',
+            'comments': 'New comments',
+
+        }

+ 289 - 66
netbox/dcim/tests/test_views.py

@@ -19,6 +19,7 @@ class RegionTestCase(StandardTestCases.Views):
     # Disable inapplicable tests
     test_get_object = None
     test_delete_object = None
+    test_bulk_edit_objects = None
 
     @classmethod
     def setUpTestData(cls):
@@ -52,20 +53,24 @@ class SiteTestCase(StandardTestCases.Views):
     @classmethod
     def setUpTestData(cls):
 
-        region = Region(name='Region 1', slug='region-1')
-        region.save()
+        regions = (
+            Region(name='Region 1', slug='region-1'),
+            Region(name='Region 2', slug='region-2'),
+        )
+        for region in regions:
+            region.save()
 
         Site.objects.bulk_create([
-            Site(name='Site 1', slug='site-1', region=region),
-            Site(name='Site 2', slug='site-2', region=region),
-            Site(name='Site 3', slug='site-3', region=region),
+            Site(name='Site 1', slug='site-1', region=regions[0]),
+            Site(name='Site 2', slug='site-2', region=regions[0]),
+            Site(name='Site 3', slug='site-3', region=regions[0]),
         ])
 
         cls.form_data = {
             'name': 'Site X',
             'slug': 'site-x',
             'status': SiteStatusChoices.STATUS_PLANNED,
-            'region': region.pk,
+            'region': regions[1].pk,
             'tenant': None,
             'facility': 'Facility X',
             'asn': 65001,
@@ -89,6 +94,15 @@ class SiteTestCase(StandardTestCases.Views):
             "Site 6,site-6",
         )
 
+        cls.bulk_edit_data = {
+            'status': SiteStatusChoices.STATUS_PLANNED,
+            'region': regions[1].pk,
+            'tenant': None,
+            'asn': 65009,
+            'time_zone': pytz.timezone('US/Eastern'),
+            'description': 'New description',
+        }
+
 
 class RackGroupTestCase(StandardTestCases.Views):
     model = RackGroup
@@ -96,6 +110,7 @@ class RackGroupTestCase(StandardTestCases.Views):
     # Disable inapplicable tests
     test_get_object = None
     test_delete_object = None
+    test_bulk_edit_objects = None
 
     @classmethod
     def setUpTestData(cls):
@@ -129,6 +144,7 @@ class RackRoleTestCase(StandardTestCases.Views):
     # Disable inapplicable tests
     test_get_object = None
     test_delete_object = None
+    test_bulk_edit_objects = None
 
     @classmethod
     def setUpTestData(cls):
@@ -159,32 +175,40 @@ class RackReservationTestCase(StandardTestCases.Views):
 
     # Disable inapplicable tests
     test_get_object = None
-    test_create_object = None  # TODO: Fix URL name for view
+    test_create_object = None
+
+    # TODO: Fix URL name for view
     test_import_objects = None
 
     @classmethod
     def setUpTestData(cls):
 
-        user = User.objects.create_user(username='testuser2')
+        user2 = User.objects.create_user(username='testuser2')
+        user3 = User.objects.create_user(username='testuser3')
 
-        site = Site(name='Site 1', slug='site-1')
-        site.save()
+        site = Site.objects.create(name='Site 1', slug='site-1')
 
         rack = Rack(name='Rack 1', site=site)
         rack.save()
 
         RackReservation.objects.bulk_create([
-            RackReservation(rack=rack, user=user, units=[1, 2, 3], description='Reservation 1'),
-            RackReservation(rack=rack, user=user, units=[4, 5, 6], description='Reservation 2'),
-            RackReservation(rack=rack, user=user, units=[7, 8, 9], description='Reservation 3'),
+            RackReservation(rack=rack, user=user2, units=[1, 2, 3], description='Reservation 1'),
+            RackReservation(rack=rack, user=user2, units=[4, 5, 6], description='Reservation 2'),
+            RackReservation(rack=rack, user=user2, units=[7, 8, 9], description='Reservation 3'),
         ])
 
         cls.form_data = {
             'rack': rack.pk,
             'units': [10, 11, 12],
-            'user': user.pk,
+            'user': user3.pk,
             'tenant': None,
-            'description': 'New reservation',
+            'description': 'Rack reservation',
+        }
+
+        cls.bulk_edit_data = {
+            'user': user3.pk,
+            'tenant': None,
+            'description': 'New description',
         }
 
 
@@ -194,24 +218,38 @@ class RackTestCase(StandardTestCases.Views):
     @classmethod
     def setUpTestData(cls):
 
-        site = Site.objects.create(name='Site 1', slug='site-1')
-        rackgroup = RackGroup.objects.create(name='Rack Group 1', slug='rack-group-1', site=site)
-        rackrole = RackRole.objects.create(name='Rack Role 1', slug='rack-role-1')
+        sites = (
+            Site(name='Site 1', slug='site-1'),
+            Site(name='Site 2', slug='site-2'),
+        )
+        Site.objects.bulk_create(sites)
 
-        Rack.objects.bulk_create([
-            Rack(name='Rack 1', site=site),
-            Rack(name='Rack 2', site=site),
-            Rack(name='Rack 3', site=site),
-        ])
+        rackgroups = (
+            RackGroup(name='Rack Group 1', slug='rack-group-1', site=sites[0]),
+            RackGroup(name='Rack Group 2', slug='rack-group-2', site=sites[1])
+        )
+        RackGroup.objects.bulk_create(rackgroups)
+
+        rackroles = (
+            RackRole(name='Rack Role 1', slug='rack-role-1'),
+            RackRole(name='Rack Role 2', slug='rack-role-2'),
+        )
+        RackRole.objects.bulk_create(rackroles)
+
+        Rack.objects.bulk_create((
+            Rack(name='Rack 1', site=sites[0]),
+            Rack(name='Rack 2', site=sites[0]),
+            Rack(name='Rack 3', site=sites[0]),
+        ))
 
         cls.form_data = {
             'name': 'Rack X',
             'facility_id': 'Facility X',
-            'site': site.pk,
-            'group': rackgroup.pk,
+            'site': sites[1].pk,
+            'group': rackgroups[1].pk,
             'tenant': None,
             'status': RackStatusChoices.STATUS_PLANNED,
-            'role': rackrole.pk,
+            'role': rackroles[1].pk,
             'serial': '123456',
             'asset_tag': 'ABCDEF',
             'type': RackTypeChoices.TYPE_CABINET,
@@ -232,6 +270,23 @@ class RackTestCase(StandardTestCases.Views):
             "Site 1,Rack 6,19,42",
         )
 
+        cls.bulk_edit_data = {
+            'site': sites[1].pk,
+            'group': rackgroups[1].pk,
+            'tenant': None,
+            'status': RackStatusChoices.STATUS_DEPRECATED,
+            'role': rackroles[1].pk,
+            'serial': '654321',
+            'type': RackTypeChoices.TYPE_4POST,
+            'width': RackWidthChoices.WIDTH_23IN,
+            'u_height': 49,
+            'desc_units': True,
+            'outer_width': 30,
+            'outer_depth': 30,
+            'outer_unit': RackDimensionUnitChoices.UNIT_INCH,
+            'comments': 'New comments',
+        }
+
 
 class ManufacturerTestCase(StandardTestCases.Views):
     model = Manufacturer
@@ -239,6 +294,7 @@ class ManufacturerTestCase(StandardTestCases.Views):
     # Disable inapplicable tests
     test_get_object = None
     test_delete_object = None
+    test_bulk_edit_objects = None
 
     @classmethod
     def setUpTestData(cls):
@@ -268,17 +324,20 @@ class DeviceTypeTestCase(StandardTestCases.Views):
     @classmethod
     def setUpTestData(cls):
 
-        manufacturer = Manufacturer(name='Manufacturer 1', slug='manufacturer-1')
-        manufacturer.save()
+        manufacturers = (
+            Manufacturer(name='Manufacturer 1', slug='manufacturer-1'),
+            Manufacturer(name='Manufacturer 2', slug='manufacturer-2')
+        )
+        Manufacturer.objects.bulk_create(manufacturers)
 
         DeviceType.objects.bulk_create([
-            DeviceType(model='Device Type 1', slug='device-type-1', manufacturer=manufacturer),
-            DeviceType(model='Device Type 2', slug='device-type-2', manufacturer=manufacturer),
-            DeviceType(model='Device Type 3', slug='device-type-3', manufacturer=manufacturer),
+            DeviceType(model='Device Type 1', slug='device-type-1', manufacturer=manufacturers[0]),
+            DeviceType(model='Device Type 2', slug='device-type-2', manufacturer=manufacturers[0]),
+            DeviceType(model='Device Type 3', slug='device-type-3', manufacturer=manufacturers[0]),
         ])
 
         cls.form_data = {
-            'manufacturer': manufacturer.pk,
+            'manufacturer': manufacturers[1].pk,
             'model': 'Device Type X',
             'slug': 'device-type-x',
             'part_number': '123ABC',
@@ -289,6 +348,12 @@ class DeviceTypeTestCase(StandardTestCases.Views):
             'tags': 'Alpha,Bravo,Charlie',
         }
 
+        cls.bulk_edit_data = {
+            'manufacturer': manufacturers[1].pk,
+            'u_height': 3,
+            'is_full_depth': False,
+        }
+
     def test_import_objects(self):
         """
         Custom import test for YAML-based imports (versus CSV)
@@ -451,6 +516,7 @@ class DeviceRoleTestCase(StandardTestCases.Views):
     # Disable inapplicable tests
     test_get_object = None
     test_delete_object = None
+    test_bulk_edit_objects = None
 
     @classmethod
     def setUpTestData(cls):
@@ -483,6 +549,7 @@ class PlatformTestCase(StandardTestCases.Views):
     # Disable inapplicable tests
     test_get_object = None
     test_delete_object = None
+    test_bulk_edit_objects = None
 
     @classmethod
     def setUpTestData(cls):
@@ -517,29 +584,54 @@ class DeviceTestCase(StandardTestCases.Views):
     @classmethod
     def setUpTestData(cls):
 
-        site = Site.objects.create(name='Site 1', slug='site-1')
-        rack = Rack.objects.create(name='Rack 1', site=site)
+        sites = (
+            Site(name='Site 1', slug='site-1'),
+            Site(name='Site 2', slug='site-2'),
+        )
+        Site.objects.bulk_create(sites)
+
+        racks = (
+            Rack(name='Rack 1', site=sites[0]),
+            Rack(name='Rack 2', site=sites[1]),
+        )
+        Rack.objects.bulk_create(racks)
+
         manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
-        devicetype = DeviceType.objects.create(model='Device Type 1', manufacturer=manufacturer)
-        devicerole = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
-        platform = Platform.objects.create(name='Platform 1', slug='platform-1')
+
+        devicetypes = (
+            DeviceType(model='Device Type 1', slug='device-type-1', manufacturer=manufacturer),
+            DeviceType(model='Device Type 2', slug='device-type-2', manufacturer=manufacturer),
+        )
+        DeviceType.objects.bulk_create(devicetypes)
+
+        deviceroles = (
+            DeviceRole(name='Device Role 1', slug='device-role-1'),
+            DeviceRole(name='Device Role 2', slug='device-role-2'),
+        )
+        DeviceRole.objects.bulk_create(deviceroles)
+
+        platforms = (
+            Platform(name='Platform 1', slug='platform-1'),
+            Platform(name='Platform 2', slug='platform-2'),
+        )
+        Platform.objects.bulk_create(platforms)
 
         Device.objects.bulk_create([
-            Device(name='Device 1', site=site, device_type=devicetype, device_role=devicerole),
-            Device(name='Device 2', site=site, device_type=devicetype, device_role=devicerole),
-            Device(name='Device 3', site=site, device_type=devicetype, device_role=devicerole),
+            Device(name='Device 1', site=sites[0], rack=racks[0], device_type=devicetypes[0], device_role=deviceroles[0], platform=platforms[0]),
+            Device(name='Device 2', site=sites[0], rack=racks[0], device_type=devicetypes[0], device_role=deviceroles[0], platform=platforms[0]),
+            Device(name='Device 3', site=sites[0], rack=racks[0], device_type=devicetypes[0], device_role=deviceroles[0], platform=platforms[0]),
         ])
 
         cls.form_data = {
-            'device_type': devicetype.pk,
-            'device_role': devicerole.pk,
+            'device_type': devicetypes[1].pk,
+            'device_role': deviceroles[1].pk,
             'tenant': None,
-            'platform': platform.pk,
+            'platform': platforms[1].pk,
             'name': 'Device X',
             'serial': '123456',
             'asset_tag': 'ABCDEF',
-            'site': site.pk,
-            'rack': rack.pk,
+            'site': sites[1].pk,
+            'rack': racks[1].pk,
             'position': 1,
             'face': DeviceFaceChoices.FACE_FRONT,
             'status': DeviceStatusChoices.STATUS_PLANNED,
@@ -561,6 +653,15 @@ class DeviceTestCase(StandardTestCases.Views):
             "Device Role 1,Manufacturer 1,Device Type 1,Active,Site 1,Device 6",
         )
 
+        cls.bulk_edit_data = {
+            'device_type': devicetypes[1].pk,
+            'device_role': deviceroles[1].pk,
+            'tenant': None,
+            'platform': platforms[1].pk,
+            'serial': '123456',
+            'status': DeviceStatusChoices.STATUS_DECOMMISSIONING,
+        }
+
 
 # TODO: Convert to StandardTestCases.Views
 class ConsolePortTestCase(TestCase):
@@ -1071,28 +1172,28 @@ class CableTestCase(StandardTestCases.Views):
         manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
         devicetype = DeviceType.objects.create(model='Device Type 1', manufacturer=manufacturer)
         devicerole = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
-        device1 = Device(name='Device 1', site=site, device_type=devicetype, device_role=devicerole)
-        device1.save()
-        device2 = Device(name='Device 2', site=site, device_type=devicetype, device_role=devicerole)
-        device2.save()
-        device3 = Device(name='Device 3', site=site, device_type=devicetype, device_role=devicerole)
-        device3.save()
-        device4 = Device(name='Device 4', site=site, device_type=devicetype, device_role=devicerole)
-        device4.save()
+
+        devices = (
+            Device(name='Device 1', site=site, device_type=devicetype, device_role=devicerole),
+            Device(name='Device 2', site=site, device_type=devicetype, device_role=devicerole),
+            Device(name='Device 3', site=site, device_type=devicetype, device_role=devicerole),
+            Device(name='Device 4', site=site, device_type=devicetype, device_role=devicerole),
+        )
+        Device.objects.bulk_create(devices)
 
         interfaces = (
-            Interface(device=device1, name='Interface 1', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
-            Interface(device=device1, name='Interface 2', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
-            Interface(device=device1, name='Interface 3', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
-            Interface(device=device2, name='Interface 1', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
-            Interface(device=device2, name='Interface 2', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
-            Interface(device=device2, name='Interface 3', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
-            Interface(device=device3, name='Interface 1', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
-            Interface(device=device3, name='Interface 2', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
-            Interface(device=device3, name='Interface 3', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
-            Interface(device=device4, name='Interface 1', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
-            Interface(device=device4, name='Interface 2', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
-            Interface(device=device4, name='Interface 3', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
+            Interface(device=devices[0], name='Interface 1', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
+            Interface(device=devices[0], name='Interface 2', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
+            Interface(device=devices[0], name='Interface 3', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
+            Interface(device=devices[1], name='Interface 1', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
+            Interface(device=devices[1], name='Interface 2', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
+            Interface(device=devices[1], name='Interface 3', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
+            Interface(device=devices[2], name='Interface 1', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
+            Interface(device=devices[2], name='Interface 2', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
+            Interface(device=devices[2], name='Interface 3', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
+            Interface(device=devices[3], name='Interface 1', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
+            Interface(device=devices[3], name='Interface 2', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
+            Interface(device=devices[3], name='Interface 3', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
         )
         Interface.objects.bulk_create(interfaces)
 
@@ -1109,7 +1210,7 @@ class CableTestCase(StandardTestCases.Views):
             'termination_b_id': interfaces[3].pk,
             'type': CableTypeChoices.TYPE_CAT6,
             'status': CableStatusChoices.STATUS_PLANNED,
-            'label': 'New cable',
+            'label': 'Label',
             'color': 'c0c0c0',
             'length': 100,
             'length_unit': CableLengthUnitChoices.UNIT_FOOT,
@@ -1122,6 +1223,15 @@ class CableTestCase(StandardTestCases.Views):
             "Device 3,interface,Interface 3,Device 4,interface,Interface 3",
         )
 
+        cls.bulk_edit_data = {
+            'type': CableTypeChoices.TYPE_CAT5E,
+            'status': CableStatusChoices.STATUS_CONNECTED,
+            'label': 'New label',
+            'color': '00ff00',
+            'length': 50,
+            'length_unit': CableLengthUnitChoices.UNIT_METER,
+        }
+
 
 class VirtualChassisTestCase(StandardTestCases.Views):
     model = VirtualChassis
@@ -1129,6 +1239,8 @@ class VirtualChassisTestCase(StandardTestCases.Views):
     # Disable inapplicable tests
     test_get_object = None
     test_import_objects = None
+    test_bulk_edit_objects = None
+    test_bulk_delete_objects = None
 
     # TODO: Requires special form handling
     test_create_object = None
@@ -1173,3 +1285,114 @@ class VirtualChassisTestCase(StandardTestCases.Views):
         Device.objects.filter(pk=device4.pk).update(virtual_chassis=vc2, vc_position=2)
         vc3 = VirtualChassis.objects.create(master=device5, domain='test-domain-3')
         Device.objects.filter(pk=device6.pk).update(virtual_chassis=vc3, vc_position=2)
+
+
+class PowerPanelTestCase(StandardTestCases.Views):
+    model = PowerPanel
+
+    # Disable inapplicable tests
+    test_bulk_edit_objects = None
+
+    @classmethod
+    def setUpTestData(cls):
+
+        sites = (
+            Site(name='Site 1', slug='site-1'),
+            Site(name='Site 2', slug='site-2'),
+        )
+        Site.objects.bulk_create(sites)
+
+        rackgroups = (
+            RackGroup(name='Rack Group 1', slug='rack-group-1', site=sites[0]),
+            RackGroup(name='Rack Group 2', slug='rack-group-2', site=sites[1]),
+        )
+        RackGroup.objects.bulk_create(rackgroups)
+
+        PowerPanel.objects.bulk_create((
+            PowerPanel(site=sites[0], rack_group=rackgroups[0], name='Power Panel 1'),
+            PowerPanel(site=sites[0], rack_group=rackgroups[0], name='Power Panel 2'),
+            PowerPanel(site=sites[0], rack_group=rackgroups[0], name='Power Panel 3'),
+        ))
+
+        cls.form_data = {
+            'site': sites[1].pk,
+            'rack_group': rackgroups[1].pk,
+            'name': 'Power Panel X',
+        }
+
+        cls.csv_data = (
+            "site,rack_group_name,name",
+            "Site 1,Rack Group 1,Power Panel 4",
+            "Site 1,Rack Group 1,Power Panel 5",
+            "Site 1,Rack Group 1,Power Panel 6",
+        )
+
+
+class PowerFeedTestCase(StandardTestCases.Views):
+    model = PowerFeed
+
+    # TODO: Re-enable this test once #4079 is fixed
+    test_bulk_edit_objects = None
+
+    @classmethod
+    def setUpTestData(cls):
+
+        site = Site.objects.create(name='Site 1', slug='site-1')
+
+        powerpanels = (
+            PowerPanel(site=site, name='Power Panel 1'),
+            PowerPanel(site=site, name='Power Panel 2'),
+        )
+        PowerPanel.objects.bulk_create(powerpanels)
+
+        racks = (
+            Rack(site=site, name='Rack 1'),
+            Rack(site=site, name='Rack 2'),
+        )
+        Rack.objects.bulk_create(racks)
+
+        PowerFeed.objects.bulk_create((
+            PowerFeed(name='Power Feed 1', power_panel=powerpanels[0], rack=racks[0]),
+            PowerFeed(name='Power Feed 2', power_panel=powerpanels[0], rack=racks[0]),
+            PowerFeed(name='Power Feed 3', power_panel=powerpanels[0], rack=racks[0]),
+        ))
+
+        cls.form_data = {
+            'name': 'Power Feed X',
+            'power_panel': powerpanels[1].pk,
+            'rack': racks[1].pk,
+            'status': PowerFeedStatusChoices.STATUS_PLANNED,
+            'type': PowerFeedTypeChoices.TYPE_REDUNDANT,
+            'supply': PowerFeedSupplyChoices.SUPPLY_DC,
+            'phase': PowerFeedPhaseChoices.PHASE_3PHASE,
+            'voltage': 100,
+            'amperage': 100,
+            'max_utilization': 50,
+            'comments': 'New comments',
+            'tags': 'Alpha,Bravo,Charlie',
+
+            # Connection
+            'cable': None,
+            'connected_endpoint': None,
+            'connection_status': None,
+        }
+
+        cls.csv_data = (
+            "site,panel_name,name,voltage,amperage,max_utilization",
+            "Site 1,Power Panel 1,Power Feed 4,120,20,80",
+            "Site 1,Power Panel 1,Power Feed 5,120,20,80",
+            "Site 1,Power Panel 1,Power Feed 6,120,20,80",
+        )
+
+        cls.bulk_edit_data = {
+            'power_panel': powerpanels[1].pk,
+            'rack': racks[1].pk,
+            'status': PowerFeedStatusChoices.STATUS_PLANNED,
+            'type': PowerFeedTypeChoices.TYPE_REDUNDANT,
+            'supply': PowerFeedSupplyChoices.SUPPLY_DC,
+            'phase': PowerFeedPhaseChoices.PHASE_3PHASE,
+            'voltage': 100,
+            'amperage': 100,
+            'max_utilization': 50,
+            'comments': 'New comments',
+        }

+ 1 - 1
netbox/dcim/urls.py

@@ -318,7 +318,7 @@ urlpatterns = [
 
     # Power feeds
     path(r'power-feeds/', views.PowerFeedListView.as_view(), name='powerfeed_list'),
-    path(r'power-feeds/add/', views.PowerFeedEditView.as_view(), name='powerfeed_add'),
+    path(r'power-feeds/add/', views.PowerFeedCreateView.as_view(), name='powerfeed_add'),
     path(r'power-feeds/import/', views.PowerFeedBulkImportView.as_view(), name='powerfeed_import'),
     path(r'power-feeds/edit/', views.PowerFeedBulkEditView.as_view(), name='powerfeed_bulk_edit'),
     path(r'power-feeds/delete/', views.PowerFeedBulkDeleteView.as_view(), name='powerfeed_bulk_delete'),

+ 12 - 1
netbox/extras/tests/test_views.py

@@ -33,6 +33,10 @@ class TagTestCase(StandardTestCases.Views):
             'comments': 'Some comments',
         }
 
+        cls.bulk_edit_data = {
+            'color': '00ff00',
+        }
+
 
 class ConfigContextTestCase(StandardTestCases.Views):
     model = ConfigContext
@@ -53,7 +57,7 @@ class ConfigContextTestCase(StandardTestCases.Views):
         for i in range(1, 4):
             configcontext = ConfigContext(
                 name='Config Context {}'.format(i),
-                data='{{"foo": {}}}'.format(i)
+                data={'foo': i}
             )
             configcontext.save()
             configcontext.sites.add(site)
@@ -73,7 +77,14 @@ class ConfigContextTestCase(StandardTestCases.Views):
             'data': '{"foo": 123}',
         }
 
+        cls.bulk_edit_data = {
+            'weight': 300,
+            'is_active': False,
+            'description': 'New description',
+        }
+
 
+# TODO: Convert to StandardTestCases.Views
 class ObjectChangeTestCase(TestCase):
     user_permissions = (
         'extras.view_objectchange',

+ 1 - 2
netbox/extras/views.py

@@ -85,10 +85,9 @@ class TagBulkEditView(PermissionRequiredMixin, BulkEditView):
     ).order_by(
         'name'
     )
-    # filter = filters.ProviderFilter
     table = TagTable
     form = forms.TagBulkEditForm
-    default_return_url = 'circuits:provider_list'
+    default_return_url = 'extras:tag_list'
 
 
 class TagBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):

+ 1 - 1
netbox/ipam/forms.py

@@ -1392,5 +1392,5 @@ class ServiceBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
 
     class Meta:
         nullable_fields = [
-            'site', 'tenant', 'role', 'description',
+            'description',
         ]

+ 111 - 29
netbox/ipam/tests/test_views.py

@@ -36,6 +36,12 @@ class VRFTestCase(StandardTestCases.Views):
             "VRF 6",
         )
 
+        cls.bulk_edit_data = {
+            'tenant': None,
+            'enforce_unique': False,
+            'description': 'New description',
+        }
+
 
 class RIRTestCase(StandardTestCases.Views):
     model = RIR
@@ -43,6 +49,7 @@ class RIRTestCase(StandardTestCases.Views):
     # Disable inapplicable tests
     test_get_object = None
     test_delete_object = None
+    test_bulk_edit_objects = None
 
     @classmethod
     def setUpTestData(cls):
@@ -73,18 +80,22 @@ class AggregateTestCase(StandardTestCases.Views):
     @classmethod
     def setUpTestData(cls):
 
-        rir = RIR.objects.create(name='RIR 1', slug='rir-1')
+        rirs = (
+            RIR(name='RIR 1', slug='rir-1'),
+            RIR(name='RIR 2', slug='rir-2'),
+        )
+        RIR.objects.bulk_create(rirs)
 
         Aggregate.objects.bulk_create([
-            Aggregate(family=4, prefix=IPNetwork('10.1.0.0/16'), rir=rir),
-            Aggregate(family=4, prefix=IPNetwork('10.2.0.0/16'), rir=rir),
-            Aggregate(family=4, prefix=IPNetwork('10.3.0.0/16'), rir=rir),
+            Aggregate(family=4, prefix=IPNetwork('10.1.0.0/16'), rir=rirs[0]),
+            Aggregate(family=4, prefix=IPNetwork('10.2.0.0/16'), rir=rirs[0]),
+            Aggregate(family=4, prefix=IPNetwork('10.3.0.0/16'), rir=rirs[0]),
         ])
 
         cls.form_data = {
             'family': 4,
             'prefix': IPNetwork('10.99.0.0/16'),
-            'rir': rir.pk,
+            'rir': rirs[1].pk,
             'date_added': datetime.date(2020, 1, 1),
             'description': 'A new aggregate',
             'tags': 'Alpha,Bravo,Charlie',
@@ -97,6 +108,12 @@ class AggregateTestCase(StandardTestCases.Views):
             "10.6.0.0/16,RIR 1",
         )
 
+        cls.bulk_edit_data = {
+            'rir': rirs[1].pk,
+            'date_added': datetime.date(2020, 1, 1),
+            'description': 'New description',
+        }
+
 
 class RoleTestCase(StandardTestCases.Views):
     model = Role
@@ -104,6 +121,7 @@ class RoleTestCase(StandardTestCases.Views):
     # Disable inapplicable tests
     test_get_object = None
     test_delete_object = None
+    test_bulk_edit_objects = None
 
     @classmethod
     def setUpTestData(cls):
@@ -135,25 +153,37 @@ class PrefixTestCase(StandardTestCases.Views):
     @classmethod
     def setUpTestData(cls):
 
-        site = Site.objects.create(name='Site 1', slug='site-1')
-        vrf = VRF.objects.create(name='VRF 1', rd='65000:1')
-        role = Role.objects.create(name='Role 1', slug='role-1')
-        # vlan = VLAN.objects.create(vid=123, name='VLAN 123')
+        sites = (
+            Site(name='Site 1', slug='site-1'),
+            Site(name='Site 2', slug='site-2'),
+        )
+        Site.objects.bulk_create(sites)
+
+        vrfs = (
+            VRF(name='VRF 1', rd='65000:1'),
+            VRF(name='VRF 2', rd='65000:2'),
+        )
+        VRF.objects.bulk_create(vrfs)
+
+        roles = (
+            Role(name='Role 1', slug='role-1'),
+            Role(name='Role 2', slug='role-2'),
+        )
 
         Prefix.objects.bulk_create([
-            Prefix(family=4, prefix=IPNetwork('10.1.0.0/16'), site=site),
-            Prefix(family=4, prefix=IPNetwork('10.2.0.0/16'), site=site),
-            Prefix(family=4, prefix=IPNetwork('10.3.0.0/16'), site=site),
+            Prefix(family=4, prefix=IPNetwork('10.1.0.0/16'), vrf=vrfs[0], site=sites[0], role=roles[0]),
+            Prefix(family=4, prefix=IPNetwork('10.2.0.0/16'), vrf=vrfs[0], site=sites[0], role=roles[0]),
+            Prefix(family=4, prefix=IPNetwork('10.3.0.0/16'), vrf=vrfs[0], site=sites[0], role=roles[0]),
         ])
 
         cls.form_data = {
             'prefix': IPNetwork('192.0.2.0/24'),
-            'site': site.pk,
-            'vrf': vrf.pk,
+            'site': sites[1].pk,
+            'vrf': vrfs[1].pk,
             'tenant': None,
             'vlan': None,
             'status': PrefixStatusChoices.STATUS_RESERVED,
-            'role': role.pk,
+            'role': roles[1].pk,
             'is_pool': True,
             'description': 'A new prefix',
             'tags': 'Alpha,Bravo,Charlie',
@@ -166,6 +196,16 @@ class PrefixTestCase(StandardTestCases.Views):
             "10.6.0.0/16,Active",
         )
 
+        cls.bulk_edit_data = {
+            'site': sites[1].pk,
+            'vrf': vrfs[1].pk,
+            'tenant': None,
+            'status': PrefixStatusChoices.STATUS_RESERVED,
+            'role': roles[1].pk,
+            'is_pool': False,
+            'description': 'New description',
+        }
+
 
 class IPAddressTestCase(StandardTestCases.Views):
     model = IPAddress
@@ -173,16 +213,19 @@ class IPAddressTestCase(StandardTestCases.Views):
     @classmethod
     def setUpTestData(cls):
 
-        vrf = VRF.objects.create(name='VRF 1', rd='65000:1')
+        vrfs = (
+            VRF(name='VRF 1', rd='65000:1'),
+            VRF(name='VRF 2', rd='65000:2'),
+        )
 
         IPAddress.objects.bulk_create([
-            IPAddress(family=4, address=IPNetwork('192.0.2.1/24'), vrf=vrf),
-            IPAddress(family=4, address=IPNetwork('192.0.2.2/24'), vrf=vrf),
-            IPAddress(family=4, address=IPNetwork('192.0.2.3/24'), vrf=vrf),
+            IPAddress(family=4, address=IPNetwork('192.0.2.1/24'), vrf=vrfs[0]),
+            IPAddress(family=4, address=IPNetwork('192.0.2.2/24'), vrf=vrfs[0]),
+            IPAddress(family=4, address=IPNetwork('192.0.2.3/24'), vrf=vrfs[0]),
         ])
 
         cls.form_data = {
-            'vrf': vrf.pk,
+            'vrf': vrfs[1].pk,
             'address': IPNetwork('192.0.2.99/24'),
             'tenant': None,
             'status': IPAddressStatusChoices.STATUS_RESERVED,
@@ -201,6 +244,15 @@ class IPAddressTestCase(StandardTestCases.Views):
             "192.0.2.6/24,Active",
         )
 
+        cls.bulk_edit_data = {
+            'vrf': vrfs[1].pk,
+            'tenant': None,
+            'status': IPAddressStatusChoices.STATUS_RESERVED,
+            'role': IPAddressRoleChoices.ROLE_ANYCAST,
+            'dns_name': 'example',
+            'description': 'New description',
+        }
+
 
 class VLANGroupTestCase(StandardTestCases.Views):
     model = VLANGroup
@@ -208,6 +260,7 @@ class VLANGroupTestCase(StandardTestCases.Views):
     # Disable inapplicable tests
     test_get_object = None
     test_delete_object = None
+    test_bulk_edit_objects = None
 
     @classmethod
     def setUpTestData(cls):
@@ -240,24 +293,38 @@ class VLANTestCase(StandardTestCases.Views):
     @classmethod
     def setUpTestData(cls):
 
-        site = Site.objects.create(name='Site 1', slug='site-1')
-        vlangroup = VLANGroup.objects.create(name='VLAN Group 1', slug='vlan-group-1', site=site)
-        role = Role.objects.create(name='Role 1', slug='role-1')
+        sites = (
+            Site(name='Site 1', slug='site-1'),
+            Site(name='Site 2', slug='site-2'),
+        )
+        Site.objects.bulk_create(sites)
+
+        vlangroups = (
+            VLANGroup(name='VLAN Group 1', slug='vlan-group-1', site=sites[0]),
+            VLANGroup(name='VLAN Group 2', slug='vlan-group-2', site=sites[1]),
+        )
+        VLANGroup.objects.bulk_create(vlangroups)
+
+        roles = (
+            Role(name='Role 1', slug='role-1'),
+            Role(name='Role 2', slug='role-2'),
+        )
+        Role.objects.bulk_create(roles)
 
         VLAN.objects.bulk_create([
-            VLAN(group=vlangroup, vid=101, name='VLAN101'),
-            VLAN(group=vlangroup, vid=102, name='VLAN102'),
-            VLAN(group=vlangroup, vid=103, name='VLAN103'),
+            VLAN(group=vlangroups[0], vid=101, name='VLAN101', site=sites[0], role=roles[0]),
+            VLAN(group=vlangroups[0], vid=102, name='VLAN102', site=sites[0], role=roles[0]),
+            VLAN(group=vlangroups[0], vid=103, name='VLAN103', site=sites[0], role=roles[0]),
         ])
 
         cls.form_data = {
-            'site': site.pk,
-            'group': vlangroup.pk,
+            'site': sites[1].pk,
+            'group': vlangroups[1].pk,
             'vid': 999,
             'name': 'VLAN999',
             'tenant': None,
             'status': VLANStatusChoices.STATUS_RESERVED,
-            'role': role.pk,
+            'role': roles[1].pk,
             'description': 'A new VLAN',
             'tags': 'Alpha,Bravo,Charlie',
         }
@@ -269,6 +336,15 @@ class VLANTestCase(StandardTestCases.Views):
             "106,VLAN106,Active",
         )
 
+        cls.bulk_edit_data = {
+            'site': sites[1].pk,
+            'group': vlangroups[1].pk,
+            'tenant': None,
+            'status': VLANStatusChoices.STATUS_RESERVED,
+            'role': roles[1].pk,
+            'description': 'New description',
+        }
+
 
 class ServiceTestCase(StandardTestCases.Views):
     model = Service
@@ -304,3 +380,9 @@ class ServiceTestCase(StandardTestCases.Views):
             'description': 'A new service',
             'tags': 'Alpha,Bravo,Charlie',
         }
+
+        cls.bulk_edit_data = {
+            'protocol': ServiceProtocolChoices.PROTOCOL_UDP,
+            'port': 888,
+            'description': 'New description',
+        }

+ 27 - 9
netbox/secrets/tests/test_views.py

@@ -14,6 +14,7 @@ class SecretRoleTestCase(StandardTestCases.Views):
     # Disable inapplicable tests
     test_get_object = None
     test_delete_object = None
+    test_bulk_edit_objects = None
 
     @classmethod
     def setUpTestData(cls):
@@ -56,21 +57,38 @@ class SecretTestCase(StandardTestCases.Views):
         manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
         devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1')
         devicerole = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
-        device = Device.objects.create(name='Device 1', site=site, device_type=devicetype, device_role=devicerole)
-        secretrole = SecretRole.objects.create(name='Secret Role 1', slug='secret-role-1')
 
-        Secret.objects.bulk_create([
-            Secret(device=device, role=secretrole, name='Secret 1', ciphertext=b'1234567890'),
-            Secret(device=device, role=secretrole, name='Secret 2', ciphertext=b'1234567890'),
-            Secret(device=device, role=secretrole, name='Secret 3', ciphertext=b'1234567890'),
-        ])
+        devices = (
+            Device(name='Device 1', site=site, device_type=devicetype, device_role=devicerole),
+            Device(name='Device 2', site=site, device_type=devicetype, device_role=devicerole),
+            Device(name='Device 3', site=site, device_type=devicetype, device_role=devicerole),
+        )
+        Device.objects.bulk_create(devices)
+
+        secretroles = (
+            SecretRole(name='Secret Role 1', slug='secret-role-1'),
+            SecretRole(name='Secret Role 2', slug='secret-role-2'),
+        )
+        SecretRole.objects.bulk_create(secretroles)
+
+        # Create one secret per device to allow bulk-editing of names (which must be unique per device/role)
+        Secret.objects.bulk_create((
+            Secret(device=devices[0], role=secretroles[0], name='Secret 1', ciphertext=b'1234567890'),
+            Secret(device=devices[1], role=secretroles[0], name='Secret 2', ciphertext=b'1234567890'),
+            Secret(device=devices[2], role=secretroles[0], name='Secret 3', ciphertext=b'1234567890'),
+        ))
 
         cls.form_data = {
-            'device': device.pk,
-            'role': secretrole.pk,
+            'device': devices[1].pk,
+            'role': secretroles[1].pk,
             'name': 'Secret X',
         }
 
+        cls.bulk_edit_data = {
+            'role': secretroles[1].pk,
+            'name': 'New name',
+        }
+
     def setUp(self):
 
         super().setUp()

+ 14 - 5
netbox/tenancy/tests/test_views.py

@@ -8,6 +8,7 @@ class TenantGroupTestCase(StandardTestCases.Views):
     # Disable inapplicable tests
     test_get_object = None
     test_delete_object = None
+    test_bulk_edit_objects = None
 
     @classmethod
     def setUpTestData(cls):
@@ -37,18 +38,22 @@ class TenantTestCase(StandardTestCases.Views):
     @classmethod
     def setUpTestData(cls):
 
-        tenantgroup = TenantGroup.objects.create(name='Tenant Group 1', slug='tenant-group-1')
+        tenantgroups = (
+            TenantGroup(name='Tenant Group 1', slug='tenant-group-1'),
+            TenantGroup(name='Tenant Group 2', slug='tenant-group-2'),
+        )
+        TenantGroup.objects.bulk_create(tenantgroups)
 
         Tenant.objects.bulk_create([
-            Tenant(name='Tenant 1', slug='tenant-1', group=tenantgroup),
-            Tenant(name='Tenant 2', slug='tenant-2', group=tenantgroup),
-            Tenant(name='Tenant 3', slug='tenant-3', group=tenantgroup),
+            Tenant(name='Tenant 1', slug='tenant-1', group=tenantgroups[0]),
+            Tenant(name='Tenant 2', slug='tenant-2', group=tenantgroups[0]),
+            Tenant(name='Tenant 3', slug='tenant-3', group=tenantgroups[0]),
         ])
 
         cls.form_data = {
             'name': 'Tenant X',
             'slug': 'tenant-x',
-            'group': tenantgroup.pk,
+            'group': tenantgroups[1].pk,
             'description': 'A new tenant',
             'comments': 'Some comments',
             'tags': 'Alpha,Bravo,Charlie',
@@ -60,3 +65,7 @@ class TenantTestCase(StandardTestCases.Views):
             "Tenant 5,tenant-5",
             "Tenant 6,tenant-6",
         )
+
+        cls.bulk_edit_data = {
+            'group': tenantgroups[1].pk,
+        }

+ 72 - 2
netbox/utilities/testing/testcases.py

@@ -85,8 +85,15 @@ class StandardTestCases:
             - Import multiple new objects
         """
         model = None
+
+        # Data to be sent when creating/editing individual objects
         form_data = {}
-        csv_data = {}
+
+        # CSV lines used for bulk import of new objects
+        csv_data = ()
+
+        # Form data to be used when editing multiple objects at once
+        bulk_edit_data = {}
 
         maxDiff = None
 
@@ -107,7 +114,7 @@ class StandardTestCases:
                 self.model._meta.model_name
             )
 
-            if action in ('list', 'add', 'import'):
+            if action in ('list', 'add', 'import', 'bulk_edit', 'bulk_delete'):
                 return reverse(url_format.format(action))
 
             elif action in ('get', 'edit', 'delete'):
@@ -253,3 +260,66 @@ class StandardTestCases:
             self.assertHttpStatus(response, 200)
 
             self.assertEqual(self.model.objects.count(), initial_count + len(self.csv_data) - 1)
+
+        @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
+        def test_bulk_edit_objects(self):
+            pk_list = self.model.objects.values_list('pk', flat=True)
+
+            request = {
+                'path': self._get_url('bulk_edit'),
+                'data': {
+                    'pk': pk_list,
+                    '_apply': True,  # Form button
+                },
+                'follow': False,  # Do not follow 302 redirects
+            }
+
+            # Append the form data to the request
+            request['data'].update(post_data(self.bulk_edit_data))
+
+            # 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(
+                '{}.change_{}'.format(self.model._meta.app_label, self.model._meta.model_name)
+            )
+            response = self.client.post(**request)
+            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)):
+                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)
+                )
+
+        @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
+        def test_bulk_delete_objects(self):
+            pk_list = self.model.objects.values_list('pk', flat=True)
+
+            request = {
+                'path': self._get_url('bulk_delete'),
+                'data': {
+                    'pk': pk_list,
+                    'confirm': True,
+                    '_confirm': True,  # Form button
+                },
+                '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(
+                '{}.delete_{}'.format(self.model._meta.app_label, self.model._meta.model_name)
+            )
+            response = self.client.post(**request)
+            self.assertHttpStatus(response, 302)
+
+            # Check that all objects were deleted
+            self.assertEqual(self.model.objects.count(), 0)

+ 9 - 5
netbox/utilities/views.py

@@ -4,11 +4,10 @@ from copy import deepcopy
 from django.conf import settings
 from django.contrib import messages
 from django.contrib.contenttypes.models import ContentType
-from django.core.exceptions import ValidationError
+from django.core.exceptions import FieldDoesNotExist, ValidationError
 from django.db import transaction, IntegrityError
 from django.db.models import Count, ManyToManyField, ProtectedError
-from django.db.models.query import QuerySet
-from django.forms import CharField, Form, ModelMultipleChoiceField, MultipleHiddenInput, Textarea
+from django.forms import Form, ModelMultipleChoiceField, MultipleHiddenInput, Textarea
 from django.http import HttpResponse, HttpResponseServerError
 from django.shortcuts import get_object_or_404, redirect, render
 from django.template import loader
@@ -651,7 +650,7 @@ class BulkEditView(GetReturnURLMixin, View):
 
                 custom_fields = form.custom_fields if hasattr(form, 'custom_fields') else []
                 standard_fields = [
-                    field for field in form.fields if field not in custom_fields + ['pk', 'add_tags', 'remove_tags']
+                    field for field in form.fields if field not in custom_fields + ['pk']
                 ]
                 nullified_fields = request.POST.getlist('_nullify')
 
@@ -665,7 +664,12 @@ class BulkEditView(GetReturnURLMixin, View):
                             # Update standard fields. If a field is listed in _nullify, delete its value.
                             for name in standard_fields:
 
-                                model_field = model._meta.get_field(name)
+                                try:
+                                    model_field = model._meta.get_field(name)
+                                except FieldDoesNotExist:
+                                    # The form field is used to modify a field rather than set its value directly,
+                                    # so we skip it.
+                                    continue
 
                                 # Handle nullification
                                 if name in form.nullable_fields and name in nullified_fields:

+ 69 - 18
netbox/virtualization/tests/test_views.py

@@ -10,6 +10,7 @@ class ClusterGroupTestCase(StandardTestCases.Views):
     # Disable inapplicable tests
     test_get_object = None
     test_delete_object = None
+    test_bulk_edit_objects = None
 
     @classmethod
     def setUpTestData(cls):
@@ -39,6 +40,7 @@ class ClusterTypeTestCase(StandardTestCases.Views):
     # Disable inapplicable tests
     test_get_object = None
     test_delete_object = None
+    test_bulk_edit_objects = None
 
     @classmethod
     def setUpTestData(cls):
@@ -68,22 +70,36 @@ class ClusterTestCase(StandardTestCases.Views):
     @classmethod
     def setUpTestData(cls):
 
-        site = Site.objects.create(name='Site 1', slug='site-1')
-        clustergroup = ClusterGroup.objects.create(name='Cluster Group 1', slug='cluster-group-1')
-        clustertype = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1')
+        sites = (
+            Site(name='Site 1', slug='site-1'),
+            Site(name='Site 2', slug='site-2'),
+        )
+        Site.objects.bulk_create(sites)
+
+        clustergroups = (
+            ClusterGroup(name='Cluster Group 1', slug='cluster-group-1'),
+            ClusterGroup(name='Cluster Group 2', slug='cluster-group-2'),
+        )
+        ClusterGroup.objects.bulk_create(clustergroups)
+
+        clustertypes = (
+            ClusterType(name='Cluster Type 1', slug='cluster-type-1'),
+            ClusterType(name='Cluster Type 2', slug='cluster-type-2'),
+        )
+        ClusterType.objects.bulk_create(clustertypes)
 
         Cluster.objects.bulk_create([
-            Cluster(name='Cluster 1', group=clustergroup, type=clustertype),
-            Cluster(name='Cluster 2', group=clustergroup, type=clustertype),
-            Cluster(name='Cluster 3', group=clustergroup, type=clustertype),
+            Cluster(name='Cluster 1', group=clustergroups[0], type=clustertypes[0], site=sites[0]),
+            Cluster(name='Cluster 2', group=clustergroups[0], type=clustertypes[0], site=sites[0]),
+            Cluster(name='Cluster 3', group=clustergroups[0], type=clustertypes[0], site=sites[0]),
         ])
 
         cls.form_data = {
             'name': 'Cluster X',
-            'group': clustergroup.pk,
-            'type': clustertype.pk,
+            'group': clustergroups[1].pk,
+            'type': clustertypes[1].pk,
             'tenant': None,
-            'site': site.pk,
+            'site': sites[1].pk,
             'comments': 'Some comments',
             'tags': 'Alpha,Bravo,Charlie',
         }
@@ -95,6 +111,14 @@ class ClusterTestCase(StandardTestCases.Views):
             "Cluster 6,Cluster Type 1",
         )
 
+        cls.bulk_edit_data = {
+            'group': clustergroups[1].pk,
+            'type': clustertypes[1].pk,
+            'tenant': None,
+            'site': sites[1].pk,
+            'comments': 'New comments',
+        }
+
 
 class VirtualMachineTestCase(StandardTestCases.Views):
     model = VirtualMachine
@@ -102,24 +126,39 @@ class VirtualMachineTestCase(StandardTestCases.Views):
     @classmethod
     def setUpTestData(cls):
 
-        devicerole = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
-        platform = Platform.objects.create(name='Platform 1', slug='platform-1')
+        deviceroles = (
+            DeviceRole(name='Device Role 1', slug='device-role-1'),
+            DeviceRole(name='Device Role 2', slug='device-role-2'),
+        )
+        DeviceRole.objects.bulk_create(deviceroles)
+
+        platforms = (
+            Platform(name='Platform 1', slug='platform-1'),
+            Platform(name='Platform 2', slug='platform-2'),
+        )
+        Platform.objects.bulk_create(platforms)
+
         clustertype = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1')
-        cluster = Cluster.objects.create(name='Cluster 1', type=clustertype)
+
+        clusters = (
+            Cluster(name='Cluster 1', type=clustertype),
+            Cluster(name='Cluster 2', type=clustertype),
+        )
+        Cluster.objects.bulk_create(clusters)
 
         VirtualMachine.objects.bulk_create([
-            VirtualMachine(name='Virtual Machine 1', cluster=cluster),
-            VirtualMachine(name='Virtual Machine 2', cluster=cluster),
-            VirtualMachine(name='Virtual Machine 3', cluster=cluster),
+            VirtualMachine(name='Virtual Machine 1', cluster=clusters[0], role=deviceroles[0], platform=platforms[0]),
+            VirtualMachine(name='Virtual Machine 2', cluster=clusters[0], role=deviceroles[0], platform=platforms[0]),
+            VirtualMachine(name='Virtual Machine 3', cluster=clusters[0], role=deviceroles[0], platform=platforms[0]),
         ])
 
         cls.form_data = {
-            'cluster': cluster.pk,
+            'cluster': clusters[1].pk,
             'tenant': None,
-            'platform': None,
+            'platform': platforms[1].pk,
             'name': 'Virtual Machine X',
             'status': VirtualMachineStatusChoices.STATUS_STAGED,
-            'role': devicerole.pk,
+            'role': deviceroles[1].pk,
             'primary_ip4': None,
             'primary_ip6': None,
             'vcpus': 4,
@@ -136,3 +175,15 @@ class VirtualMachineTestCase(StandardTestCases.Views):
             "Virtual Machine 5,Cluster 1",
             "Virtual Machine 6,Cluster 1",
         )
+
+        cls.bulk_edit_data = {
+            'cluster': clusters[1].pk,
+            'tenant': None,
+            'platform': platforms[1].pk,
+            'status': VirtualMachineStatusChoices.STATUS_STAGED,
+            'role': deviceroles[1].pk,
+            'vcpus': 8,
+            'memory': 65535,
+            'disk': 8000,
+            'comments': 'New comments',
+        }