diff --git a/src/backend/InvenTree/InvenTree/api.py b/src/backend/InvenTree/InvenTree/api.py index 01a59b55dd..4610157e56 100644 --- a/src/backend/InvenTree/InvenTree/api.py +++ b/src/backend/InvenTree/InvenTree/api.py @@ -373,6 +373,20 @@ class NotFoundView(APIView): return self.not_found(request) +class BulkRequestSerializer(serializers.Serializer): + """Parameters for selecting items for bulk operations.""" + + items = serializers.ListField( + label='A list of primary key values', + child=serializers.IntegerField(), + required=False, + ) + + filters = serializers.DictField( + label='A dictionary of filter values', required=False + ) + + class BulkOperationMixin: """Mixin class for handling bulk data operations. @@ -532,6 +546,7 @@ class BulkDeleteMixin(BulkOperationMixin): """ return queryset + @extend_schema(request=BulkRequestSerializer) def delete(self, request, *args, **kwargs): """Perform a DELETE operation against this list endpoint. @@ -553,7 +568,7 @@ class BulkDeleteMixin(BulkOperationMixin): for item in queryset: item.delete() - return Response({'success': f'Deleted {n_deleted} items'}, status=204) + return Response({'success': f'Deleted {n_deleted} items'}, status=200) class ListCreateDestroyAPIView(BulkDeleteMixin, ListCreateAPI): diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index 35b10df141..7dfc9bffb2 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,13 +1,17 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 329 +INVENTREE_API_VERSION = 330 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v330 - 2025-03-31 : https://github.com/inventree/InvenTree/pull/9420 + - Deconflict operation id between single and bulk destroy operations + - Add request body definition for bulk destroy operations + v329 - 2025-03-30 : https://github.com/inventree/InvenTree/pull/9399 - Convert url path regex-specified PKs to int diff --git a/src/backend/InvenTree/InvenTree/schema.py b/src/backend/InvenTree/InvenTree/schema.py index 2f8976f4c6..d254418ce6 100644 --- a/src/backend/InvenTree/InvenTree/schema.py +++ b/src/backend/InvenTree/InvenTree/schema.py @@ -1,10 +1,64 @@ """Schema processing functions for cleaning up generated schema.""" +from typing import Optional + +from drf_spectacular.openapi import AutoSchema +from drf_spectacular.plumbing import ComponentRegistry +from drf_spectacular.utils import _SchemaType + + +class ExtendedAutoSchema(AutoSchema): + """Extend drf-spectacular to allow customizing the schema to match the actual API behavior.""" + + def is_bulk_delete(self) -> bool: + """Check the class of the current view for the BulkDeleteMixin.""" + return 'BulkDeleteMixin' in [c.__name__ for c in type(self.view).__mro__] + + def get_operation_id(self) -> str: + """Custom path handling overrides, falling back to default behavior.""" + result_id = super().get_operation_id() + + # rename bulk deletes to deconflict with single delete operation_id + if self.method == 'DELETE' and self.is_bulk_delete(): + action = self.method_mapping[self.method.lower()] + result_id = result_id.replace(action, 'bulk_' + action) + + return result_id + + def get_operation( + self, + path: str, + path_regex: str, + path_prefix: str, + method: str, + registry: ComponentRegistry, + ) -> Optional[_SchemaType]: + """Custom operation handling, falling back to default behavior.""" + operation = super().get_operation( + path, path_regex, path_prefix, method, registry + ) + if operation is None: + return None + + # drf-spectacular doesn't support a body on DELETE endpoints because the semantics are not well-defined and + # OpenAPI recommends against it. This allows us to generate a schema that follows existing behavior. + if self.method == 'DELETE' and self.is_bulk_delete(): + original_method = self.method + self.method = 'PUT' + request_body = self._get_request_body() + request_body['required'] = True + operation['requestBody'] = request_body + self.method = original_method + + return operation + def postprocess_required_nullable(result, generator, request, public): """Un-require nullable fields. - Read-only values are all marked as required by spectacular, but InvenTree doesn't always include them in the response. This removes them from the required list to allow responses lacking read-only nullable fields to validate against the schema. + Read-only values are all marked as required by spectacular, but InvenTree doesn't always include them in the + response. This removes them from the required list to allow responses lacking read-only nullable fields to validate + against the schema. """ # Process schema section schemas = result.get('components', {}).get('schemas', {}) diff --git a/src/backend/InvenTree/InvenTree/settings.py b/src/backend/InvenTree/InvenTree/settings.py index 348830bc02..2c71a9e0d9 100644 --- a/src/backend/InvenTree/InvenTree/settings.py +++ b/src/backend/InvenTree/InvenTree/settings.py @@ -536,7 +536,7 @@ REST_FRAMEWORK = { 'rest_framework.permissions.DjangoModelPermissions', 'InvenTree.permissions.RolePermission', ], - 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', + 'DEFAULT_SCHEMA_CLASS': 'InvenTree.schema.ExtendedAutoSchema', 'DEFAULT_METADATA_CLASS': 'InvenTree.metadata.InvenTreeMetadata', 'DEFAULT_RENDERER_CLASSES': ['rest_framework.renderers.JSONRenderer'], 'TOKEN_MODEL': 'users.models.ApiToken', diff --git a/src/backend/InvenTree/common/tests.py b/src/backend/InvenTree/common/tests.py index 8cd9f9718e..60fdf52b9f 100644 --- a/src/backend/InvenTree/common/tests.py +++ b/src/backend/InvenTree/common/tests.py @@ -1112,7 +1112,7 @@ class NotificationTest(InvenTreeAPITestCase): # Now, let's bulk delete all 'unread' notifications via the API, # but only associated with the logged in user - response = self.delete(url, {'filters': {'read': False}}, expected_code=204) + response = self.delete(url, {'filters': {'read': False}}, expected_code=200) # Only 7 notifications should have been deleted, # as the notifications associated with other users must remain untouched diff --git a/src/backend/InvenTree/order/test_api.py b/src/backend/InvenTree/order/test_api.py index 130b81e568..e82a1ac767 100644 --- a/src/backend/InvenTree/order/test_api.py +++ b/src/backend/InvenTree/order/test_api.py @@ -756,7 +756,7 @@ class PurchaseOrderLineItemTest(OrderTest): url = reverse('api-po-line-list') # Try to delete a set of line items via their IDs - self.delete(url, {'items': [1, 2]}, expected_code=204) + self.delete(url, {'items': [1, 2]}, expected_code=200) # We should have 2 less PurchaseOrderLineItems after deletign them self.assertEqual(models.PurchaseOrderLineItem.objects.count(), n - 2) diff --git a/src/backend/InvenTree/stock/test_api.py b/src/backend/InvenTree/stock/test_api.py index 459f33a022..d579edb78f 100644 --- a/src/backend/InvenTree/stock/test_api.py +++ b/src/backend/InvenTree/stock/test_api.py @@ -2015,7 +2015,7 @@ class StockTestResultTest(StockAPITestCase): # Try again, but with the correct filters this time response = self.delete( - url, {'items': tests, 'filters': {'stock_item': 1}}, expected_code=204 + url, {'items': tests, 'filters': {'stock_item': 1}}, expected_code=200 ) self.assertEqual(StockItemTestResult.objects.count(), n)