diff --git a/docs/release-notes/version-2.10.md b/docs/release-notes/version-2.10.md index 797149643..cb970d304 100644 --- a/docs/release-notes/version-2.10.md +++ b/docs/release-notes/version-2.10.md @@ -6,6 +6,20 @@ ### New Features +#### REST API Bulk Deletion ([#3436](https://github.com/netbox-community/netbox/issues/3436)) + +The REST API now supports the bulk deletion of objects of the same type in a single request. Send a `DELETE` HTTP request to the list to the model's list endpoint (e.g. `/api/dcim/sites/`) with a list of JSON objects specifying the numeric ID of each object to be deleted. For example, to delete sites with IDs 10, 11, and 12, issue the following request: + +```no-highlight +curl -s -X DELETE \ +-H "Authorization: Token $TOKEN" \ +-H "Content-Type: application/json" \ +http://netbox/api/dcim/sites/ \ +--data '[{"id": 10}, {"id": 11}, {"id": 12}]' +``` + +### Enhancements + * [#1503](https://github.com/netbox-community/netbox/issues/1503) - Allow assigment of secrets to virtual machines * [#1692](https://github.com/netbox-community/netbox/issues/1692) - Allow assigment of inventory items to parent items in web UI * [#2179](https://github.com/netbox-community/netbox/issues/2179) - Support the assignment of multiple port numbers for services @@ -23,6 +37,7 @@ ### REST API Changes +* Added support for `DELETE` operations on list endpoints * dcim.Cable: Added `custom_fields` * dcim.InventoryItem: The `_depth` field has been added to reflect MPTT positioning * dcim.PowerPanel: Added `custom_fields` diff --git a/docs/rest-api/overview.md b/docs/rest-api/overview.md index 34ea7c12f..a3c8143eb 100644 --- a/docs/rest-api/overview.md +++ b/docs/rest-api/overview.md @@ -468,16 +468,16 @@ http://netbox/api/dcim/sites/ \ ] ``` -### Modifying an Object +### Updating an Object To modify an object which has already been created, make a `PATCH` request to the model's _detail_ endpoint specifying its unique numeric ID. Include any data which you wish to update on the object. As with object creation, the `Authorization` and `Content-Type` headers must also be specified. ```no-highlight curl -s -X PATCH \ -> -H "Authorization: Token $TOKEN" \ -> -H "Content-Type: application/json" \ -> http://netbox/api/ipam/prefixes/18691/ \ -> --data '{"status": "reserved"}' | jq '.' +-H "Authorization: Token $TOKEN" \ +-H "Content-Type: application/json" \ +http://netbox/api/ipam/prefixes/18691/ \ +--data '{"status": "reserved"}' | jq '.' ``` ```json @@ -515,6 +515,23 @@ curl -s -X PATCH \ !!! note "PUT versus PATCH" The NetBox REST API support the use of either `PUT` or `PATCH` to modify an existing object. The difference is that a `PUT` request requires the user to specify a _complete_ representation of the object being modified, whereas a `PATCH` request need include only the attributes that are being updated. For most purposes, using `PATCH` is recommended. +### Updating Multiple Objects + +Multiple objects can be updated simultaneously by issuing a `PUT` or `PATCH` request to a model's list endpoint with a list of dictionaries specifying the numeric ID of each object to be deleted and the attributes to be updated. For example, to update sites with IDs 10 and 11 to a status of "active", issue the following request: + +```no-highlight +curl -s -X PATCH \ +-H "Authorization: Token $TOKEN" \ +-H "Content-Type: application/json" \ +http://netbox/api/dcim/sites/ \ +--data '[{"id": 10, "status": "active"}, {"id": 11, "status": "active"}]' +``` + +Note that there is no requirement for the attributes to be identical among objects. For instance, it's possible to update the status of one site along with the name of another in the same request. + +!!! note + The bulk update of objects is an all-or-none operation, meaning that if NetBox fails to successfully update any of the specified objects (e.g. due a validation error), the entire operation will be aborted and none of the objects will be updated. + ### Deleting an Object To delete an object from NetBox, make a `DELETE` request to the model's _detail_ endpoint specifying its unique numeric ID. The `Authorization` header must be included to specify an authorization token, however this type of request does not support passing any data in the body. @@ -537,6 +554,7 @@ NetBox supports the simultaneous deletion of multiple objects of the same type b ```no-highlight curl -s -X DELETE \ -H "Authorization: Token $TOKEN" \ +-H "Content-Type: application/json" \ http://netbox/api/dcim/sites/ \ --data '[{"id": 10}, {"id": 11}, {"id": 12}]' ``` diff --git a/netbox/circuits/tests/test_api.py b/netbox/circuits/tests/test_api.py index 8a6289401..48493d5ef 100644 --- a/netbox/circuits/tests/test_api.py +++ b/netbox/circuits/tests/test_api.py @@ -32,6 +32,9 @@ class ProviderTest(APIViewTestCases.APIViewTestCase): 'slug': 'provider-6', }, ] + bulk_update_data = { + 'asn': 1234, + } @classmethod def setUpTestData(cls): @@ -61,6 +64,9 @@ class CircuitTypeTest(APIViewTestCases.APIViewTestCase): 'slug': 'circuit-type-6', }, ) + bulk_update_data = { + 'description': 'New description', + } @classmethod def setUpTestData(cls): @@ -76,6 +82,9 @@ class CircuitTypeTest(APIViewTestCases.APIViewTestCase): class CircuitTest(APIViewTestCases.APIViewTestCase): model = Circuit brief_fields = ['cid', 'id', 'url'] + bulk_update_data = { + 'status': 'planned', + } @classmethod def setUpTestData(cls): diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 286405e54..512d7919c 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -80,6 +80,9 @@ class RegionTest(APIViewTestCases.APIViewTestCase): 'slug': 'region-6', }, ] + bulk_update_data = { + 'description': 'New description', + } @classmethod def setUpTestData(cls): @@ -92,6 +95,9 @@ class RegionTest(APIViewTestCases.APIViewTestCase): class SiteTest(APIViewTestCases.APIViewTestCase): model = Site brief_fields = ['id', 'name', 'slug', 'url'] + bulk_update_data = { + 'status': 'planned', + } @classmethod def setUpTestData(cls): @@ -133,6 +139,9 @@ class SiteTest(APIViewTestCases.APIViewTestCase): class RackGroupTest(APIViewTestCases.APIViewTestCase): model = RackGroup brief_fields = ['_depth', 'id', 'name', 'rack_count', 'slug', 'url'] + bulk_update_data = { + 'description': 'New description', + } @classmethod def setUpTestData(cls): @@ -194,6 +203,9 @@ class RackRoleTest(APIViewTestCases.APIViewTestCase): 'color': 'ffff00', }, ] + bulk_update_data = { + 'description': 'New description', + } @classmethod def setUpTestData(cls): @@ -209,6 +221,9 @@ class RackRoleTest(APIViewTestCases.APIViewTestCase): class RackTest(APIViewTestCases.APIViewTestCase): model = Rack brief_fields = ['device_count', 'display_name', 'id', 'name', 'url'] + bulk_update_data = { + 'status': 'planned', + } @classmethod def setUpTestData(cls): @@ -294,6 +309,9 @@ class RackTest(APIViewTestCases.APIViewTestCase): class RackReservationTest(APIViewTestCases.APIViewTestCase): model = RackReservation brief_fields = ['id', 'units', 'url', 'user'] + bulk_update_data = { + 'description': 'New description', + } @classmethod def setUpTestData(cls): @@ -356,6 +374,9 @@ class ManufacturerTest(APIViewTestCases.APIViewTestCase): 'slug': 'manufacturer-6', }, ] + bulk_update_data = { + 'description': 'New description', + } @classmethod def setUpTestData(cls): @@ -371,6 +392,9 @@ class ManufacturerTest(APIViewTestCases.APIViewTestCase): class DeviceTypeTest(APIViewTestCases.APIViewTestCase): model = DeviceType brief_fields = ['device_count', 'display_name', 'id', 'manufacturer', 'model', 'slug', 'url'] + bulk_update_data = { + 'part_number': 'ABC123', + } @classmethod def setUpTestData(cls): @@ -410,6 +434,9 @@ class DeviceTypeTest(APIViewTestCases.APIViewTestCase): class ConsolePortTemplateTest(APIViewTestCases.APIViewTestCase): model = ConsolePortTemplate brief_fields = ['id', 'name', 'url'] + bulk_update_data = { + 'description': 'New description', + } @classmethod def setUpTestData(cls): @@ -444,6 +471,9 @@ class ConsolePortTemplateTest(APIViewTestCases.APIViewTestCase): class ConsoleServerPortTemplateTest(APIViewTestCases.APIViewTestCase): model = ConsoleServerPortTemplate brief_fields = ['id', 'name', 'url'] + bulk_update_data = { + 'description': 'New description', + } @classmethod def setUpTestData(cls): @@ -478,6 +508,9 @@ class ConsoleServerPortTemplateTest(APIViewTestCases.APIViewTestCase): class PowerPortTemplateTest(APIViewTestCases.APIViewTestCase): model = PowerPortTemplate brief_fields = ['id', 'name', 'url'] + bulk_update_data = { + 'description': 'New description', + } @classmethod def setUpTestData(cls): @@ -512,6 +545,9 @@ class PowerPortTemplateTest(APIViewTestCases.APIViewTestCase): class PowerOutletTemplateTest(APIViewTestCases.APIViewTestCase): model = PowerOutletTemplate brief_fields = ['id', 'name', 'url'] + bulk_update_data = { + 'description': 'New description', + } @classmethod def setUpTestData(cls): @@ -546,6 +582,9 @@ class PowerOutletTemplateTest(APIViewTestCases.APIViewTestCase): class InterfaceTemplateTest(APIViewTestCases.APIViewTestCase): model = InterfaceTemplate brief_fields = ['id', 'name', 'url'] + bulk_update_data = { + 'description': 'New description', + } @classmethod def setUpTestData(cls): @@ -583,6 +622,9 @@ class InterfaceTemplateTest(APIViewTestCases.APIViewTestCase): class FrontPortTemplateTest(APIViewTestCases.APIViewTestCase): model = FrontPortTemplate brief_fields = ['id', 'name', 'url'] + bulk_update_data = { + 'description': 'New description', + } @classmethod def setUpTestData(cls): @@ -651,6 +693,9 @@ class FrontPortTemplateTest(APIViewTestCases.APIViewTestCase): class RearPortTemplateTest(APIViewTestCases.APIViewTestCase): model = RearPortTemplate brief_fields = ['id', 'name', 'url'] + bulk_update_data = { + 'description': 'New description', + } @classmethod def setUpTestData(cls): @@ -688,6 +733,9 @@ class RearPortTemplateTest(APIViewTestCases.APIViewTestCase): class DeviceBayTemplateTest(APIViewTestCases.APIViewTestCase): model = DeviceBayTemplate brief_fields = ['id', 'name', 'url'] + bulk_update_data = { + 'description': 'New description', + } @classmethod def setUpTestData(cls): @@ -739,6 +787,9 @@ class DeviceRoleTest(APIViewTestCases.APIViewTestCase): 'color': 'ffff00', }, ] + bulk_update_data = { + 'description': 'New description', + } @classmethod def setUpTestData(cls): @@ -768,6 +819,9 @@ class PlatformTest(APIViewTestCases.APIViewTestCase): 'slug': 'platform-6', }, ] + bulk_update_data = { + 'description': 'New description', + } @classmethod def setUpTestData(cls): @@ -783,6 +837,9 @@ class PlatformTest(APIViewTestCases.APIViewTestCase): class DeviceTest(APIViewTestCases.APIViewTestCase): model = Device brief_fields = ['display_name', 'id', 'name', 'url'] + bulk_update_data = { + 'status': 'failed', + } @classmethod def setUpTestData(cls): @@ -921,6 +978,9 @@ class DeviceTest(APIViewTestCases.APIViewTestCase): class ConsolePortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase): model = ConsolePort brief_fields = ['cable', 'connection_status', 'device', 'id', 'name', 'url'] + bulk_update_data = { + 'description': 'New description', + } peer_termination_type = ConsoleServerPort @classmethod @@ -957,6 +1017,9 @@ class ConsolePortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCa class ConsoleServerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase): model = ConsoleServerPort brief_fields = ['cable', 'connection_status', 'device', 'id', 'name', 'url'] + bulk_update_data = { + 'description': 'New description', + } peer_termination_type = ConsolePort @classmethod @@ -993,6 +1056,9 @@ class ConsoleServerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIView class PowerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase): model = PowerPort brief_fields = ['cable', 'connection_status', 'device', 'id', 'name', 'url'] + bulk_update_data = { + 'description': 'New description', + } peer_termination_type = PowerOutlet @classmethod @@ -1029,6 +1095,9 @@ class PowerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase class PowerOutletTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase): model = PowerOutlet brief_fields = ['cable', 'connection_status', 'device', 'id', 'name', 'url'] + bulk_update_data = { + 'description': 'New description', + } peer_termination_type = PowerPort @classmethod @@ -1065,6 +1134,9 @@ class PowerOutletTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCa class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase): model = Interface brief_fields = ['cable', 'connection_status', 'device', 'id', 'name', 'url'] + bulk_update_data = { + 'description': 'New description', + } peer_termination_type = Interface @classmethod @@ -1120,6 +1192,9 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase class FrontPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase): model = FrontPort brief_fields = ['cable', 'device', 'id', 'name', 'url'] + bulk_update_data = { + 'description': 'New description', + } peer_termination_type = Interface @classmethod @@ -1175,6 +1250,9 @@ class FrontPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase class RearPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase): model = RearPort brief_fields = ['cable', 'device', 'id', 'name', 'url'] + bulk_update_data = { + 'description': 'New description', + } peer_termination_type = Interface @classmethod @@ -1214,6 +1292,9 @@ class RearPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase) class DeviceBayTest(APIViewTestCases.APIViewTestCase): model = DeviceBay brief_fields = ['device', 'id', 'name', 'url'] + bulk_update_data = { + 'description': 'New description', + } @classmethod def setUpTestData(cls): @@ -1274,6 +1355,9 @@ class DeviceBayTest(APIViewTestCases.APIViewTestCase): class InventoryItemTest(APIViewTestCases.APIViewTestCase): model = InventoryItem brief_fields = ['_depth', 'device', 'id', 'name', 'url'] + bulk_update_data = { + 'description': 'New description', + } @classmethod def setUpTestData(cls): @@ -1309,6 +1393,10 @@ class InventoryItemTest(APIViewTestCases.APIViewTestCase): class CableTest(APIViewTestCases.APIViewTestCase): model = Cable brief_fields = ['id', 'label', 'url'] + bulk_update_data = { + 'length': 100, + 'length_unit': 'm', + } # TODO: Allow updating cable terminations test_update_object = None @@ -1894,6 +1982,9 @@ class PowerPanelTest(APIViewTestCases.APIViewTestCase): class PowerFeedTest(APIViewTestCases.APIViewTestCase): model = PowerFeed brief_fields = ['cable', 'id', 'name', 'url'] + bulk_update_data = { + 'status': 'planned', + } @classmethod def setUpTestData(cls): diff --git a/netbox/extras/tests/test_api.py b/netbox/extras/tests/test_api.py index f66fea2ac..860aed56f 100644 --- a/netbox/extras/tests/test_api.py +++ b/netbox/extras/tests/test_api.py @@ -49,6 +49,9 @@ class ExportTemplateTest(APIViewTestCases.APIViewTestCase): 'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}', }, ] + bulk_update_data = { + 'description': 'New description', + } @classmethod def setUpTestData(cls): @@ -91,6 +94,9 @@ class TagTest(APIViewTestCases.APIViewTestCase): 'slug': 'tag-6', }, ] + bulk_update_data = { + 'description': 'New description', + } @classmethod def setUpTestData(cls): @@ -164,6 +170,9 @@ class ConfigContextTest(APIViewTestCases.APIViewTestCase): 'data': {'more_baz': None}, }, ] + bulk_update_data = { + 'description': 'New description', + } @classmethod def setUpTestData(cls): diff --git a/netbox/ipam/tests/test_api.py b/netbox/ipam/tests/test_api.py index 4f514aab0..2cc24b6ae 100644 --- a/netbox/ipam/tests/test_api.py +++ b/netbox/ipam/tests/test_api.py @@ -37,6 +37,9 @@ class VRFTest(APIViewTestCases.APIViewTestCase): 'rd': '65000:6', }, ] + bulk_update_data = { + 'description': 'New description', + } @classmethod def setUpTestData(cls): @@ -66,6 +69,9 @@ class RIRTest(APIViewTestCases.APIViewTestCase): 'slug': 'rir-6', }, ] + bulk_update_data = { + 'description': 'New description', + } @classmethod def setUpTestData(cls): @@ -81,6 +87,9 @@ class RIRTest(APIViewTestCases.APIViewTestCase): class AggregateTest(APIViewTestCases.APIViewTestCase): model = Aggregate brief_fields = ['family', 'id', 'prefix', 'url'] + bulk_update_data = { + 'description': 'New description', + } @classmethod def setUpTestData(cls): @@ -131,6 +140,9 @@ class RoleTest(APIViewTestCases.APIViewTestCase): 'slug': 'role-6', }, ] + bulk_update_data = { + 'description': 'New description', + } @classmethod def setUpTestData(cls): @@ -157,6 +169,9 @@ class PrefixTest(APIViewTestCases.APIViewTestCase): 'prefix': '192.168.6.0/24', }, ] + bulk_update_data = { + 'description': 'New description', + } @classmethod def setUpTestData(cls): @@ -328,6 +343,9 @@ class IPAddressTest(APIViewTestCases.APIViewTestCase): 'address': '192.168.0.6/24', }, ] + bulk_update_data = { + 'description': 'New description', + } @classmethod def setUpTestData(cls): @@ -357,6 +375,9 @@ class VLANGroupTest(APIViewTestCases.APIViewTestCase): 'slug': 'vlan-group-6', }, ] + bulk_update_data = { + 'description': 'New description', + } @classmethod def setUpTestData(cls): @@ -372,6 +393,9 @@ class VLANGroupTest(APIViewTestCases.APIViewTestCase): class VLANTest(APIViewTestCases.APIViewTestCase): model = VLAN brief_fields = ['display_name', 'id', 'name', 'url', 'vid'] + bulk_update_data = { + 'description': 'New description', + } @classmethod def setUpTestData(cls): @@ -429,6 +453,9 @@ class VLANTest(APIViewTestCases.APIViewTestCase): class ServiceTest(APIViewTestCases.APIViewTestCase): model = Service brief_fields = ['id', 'name', 'ports', 'protocol', 'url'] + bulk_update_data = { + 'description': 'New description', + } @classmethod def setUpTestData(cls): diff --git a/netbox/netbox/api.py b/netbox/netbox/api.py index 9d65ba8b8..4b60084c6 100644 --- a/netbox/netbox/api.py +++ b/netbox/netbox/api.py @@ -12,7 +12,10 @@ from users.models import Token def is_custom_action(action): return action not in { - 'retrieve', 'list', 'create', 'update', 'partial_update', 'destroy', 'bulk_destroy' + # Default actions + 'retrieve', 'list', 'create', 'update', 'partial_update', 'destroy', + # Bulk operations + 'bulk_update', 'bulk_partial_update', 'bulk_destroy', } diff --git a/netbox/tenancy/tests/test_api.py b/netbox/tenancy/tests/test_api.py index f04b2a7ce..7af3c8d79 100644 --- a/netbox/tenancy/tests/test_api.py +++ b/netbox/tenancy/tests/test_api.py @@ -17,6 +17,9 @@ class AppTest(APITestCase): class TenantGroupTest(APIViewTestCases.APIViewTestCase): model = TenantGroup brief_fields = ['_depth', 'id', 'name', 'slug', 'tenant_count', 'url'] + bulk_update_data = { + 'description': 'New description', + } @classmethod def setUpTestData(cls): @@ -52,6 +55,9 @@ class TenantGroupTest(APIViewTestCases.APIViewTestCase): class TenantTest(APIViewTestCases.APIViewTestCase): model = Tenant brief_fields = ['id', 'name', 'slug', 'url'] + bulk_update_data = { + 'description': 'New description', + } @classmethod def setUpTestData(cls): diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py index acafd81bd..e652656d7 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -291,7 +291,7 @@ class WritableNestedSerializer(serializers.ModelSerializer): ) -class BulkDeleteSerializer(serializers.Serializer): +class BulkOperationSerializer(serializers.Serializer): id = serializers.IntegerField() @@ -299,6 +299,54 @@ class BulkDeleteSerializer(serializers.Serializer): # Mixins # +class BulkUpdateModelMixin: + """ + Support bulk modification of objects using the list endpoint for a model. Accepts a PATCH action with a list of one + or more JSON objects, each specifying the numeric ID of an object to be updated as well as the attributes to be set. + For example: + + PATCH /api/dcim/sites/ + [ + { + "id": 123, + "name": "New name" + }, + { + "id": 456, + "status": "planned" + } + ] + """ + def bulk_update(self, request, *args, **kwargs): + partial = kwargs.pop('partial', False) + serializer = BulkOperationSerializer(data=request.data, many=True) + serializer.is_valid(raise_exception=True) + qs = self.get_queryset().filter( + pk__in=[o['id'] for o in serializer.data] + ) + + # Map update data by object ID + update_data = { + obj.pop('id'): obj for obj in request.data + } + + self.perform_bulk_update(qs, update_data, partial=partial) + + return Response(status=status.HTTP_200_OK) + + def perform_bulk_update(self, objects, update_data, partial): + with transaction.atomic(): + for obj in objects: + data = update_data.get(obj.id) + serializer = self.get_serializer(obj, data=data, partial=partial) + serializer.is_valid(raise_exception=True) + self.perform_update(serializer) + + def bulk_partial_update(self, request, *args, **kwargs): + kwargs['partial'] = True + return self.bulk_update(request, *args, **kwargs) + + class BulkDestroyModelMixin: """ Support bulk deletion of objects using the list endpoint for a model. Accepts a DELETE action with a list of one @@ -310,12 +358,12 @@ class BulkDestroyModelMixin: {"id": 456} ] """ - def bulk_destroy(self, request): - serializer = BulkDeleteSerializer(data=request.data, many=True) + def bulk_destroy(self, request, *args, **kwargs): + serializer = BulkOperationSerializer(data=request.data, many=True) serializer.is_valid(raise_exception=True) - - pk_list = [o['id'] for o in serializer.data] - qs = self.get_queryset().filter(pk__in=pk_list) + qs = self.get_queryset().filter( + pk__in=[o['id'] for o in serializer.data] + ) self.perform_bulk_destroy(qs) @@ -336,6 +384,7 @@ class ModelViewSet(mixins.CreateModelMixin, mixins.UpdateModelMixin, mixins.DestroyModelMixin, mixins.ListModelMixin, + BulkUpdateModelMixin, BulkDestroyModelMixin, GenericViewSet): """ @@ -455,6 +504,8 @@ class OrderedDefaultRouter(DefaultRouter): # Extend the list view mappings to support the DELETE operation self.routes[0].mapping.update({ + 'put': 'bulk_update', + 'patch': 'bulk_partial_update', 'delete': 'bulk_destroy', }) diff --git a/netbox/utilities/testing/api.py b/netbox/utilities/testing/api.py index 2c6c70fea..222e3bdce 100644 --- a/netbox/utilities/testing/api.py +++ b/netbox/utilities/testing/api.py @@ -234,6 +234,7 @@ class APIViewTestCases: class UpdateObjectViewTestCase(APITestCase): update_data = {} + bulk_update_data = None def test_update_object_without_permission(self): """ @@ -268,6 +269,32 @@ class APIViewTestCases: instance.refresh_from_db() self.assertInstanceEqual(instance, self.update_data, api=True) + def test_bulk_update_objects(self): + """ + PATCH a set of objects in a single request. + """ + if self.bulk_update_data is None: + self.skipTest("Bulk update data not set") + + # Add object-level permission + obj_perm = ObjectPermission( + actions=['change'] + ) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.object_types.add(ContentType.objects.get_for_model(self.model)) + + id_list = self._get_queryset().values_list('id', flat=True)[:3] + self.assertEqual(len(id_list), 3, "Insufficient number of objects to test bulk update") + data = [ + {'id': id, **self.bulk_update_data} for id in id_list + ] + + response = self.client.patch(self._get_list_url(), data, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_200_OK) + for instance in self._get_queryset().filter(pk__in=id_list): + self.assertInstanceEqual(instance, self.bulk_update_data, api=True) + class DeleteObjectViewTestCase(APITestCase): def test_delete_object_without_permission(self): diff --git a/netbox/virtualization/tests/test_api.py b/netbox/virtualization/tests/test_api.py index 28d4bbb99..d0e3fccfa 100644 --- a/netbox/virtualization/tests/test_api.py +++ b/netbox/virtualization/tests/test_api.py @@ -34,6 +34,9 @@ class ClusterTypeTest(APIViewTestCases.APIViewTestCase): 'slug': 'cluster-type-6', }, ] + bulk_update_data = { + 'description': 'New description', + } @classmethod def setUpTestData(cls): @@ -63,6 +66,9 @@ class ClusterGroupTest(APIViewTestCases.APIViewTestCase): 'slug': 'cluster-type-6', }, ] + bulk_update_data = { + 'description': 'New description', + } @classmethod def setUpTestData(cls): @@ -123,6 +129,9 @@ class ClusterTest(APIViewTestCases.APIViewTestCase): class VirtualMachineTest(APIViewTestCases.APIViewTestCase): model = VirtualMachine brief_fields = ['id', 'name', 'url'] + bulk_update_data = { + 'status': 'staged', + } @classmethod def setUpTestData(cls): @@ -196,6 +205,9 @@ class VirtualMachineTest(APIViewTestCases.APIViewTestCase): class VMInterfaceTest(APIViewTestCases.APIViewTestCase): model = VMInterface brief_fields = ['id', 'name', 'url', 'virtual_machine'] + bulk_update_data = { + 'description': 'New description', + } @classmethod def setUpTestData(cls):