mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-10-31 05:05:42 +00:00 
			
		
		
		
	Add request body to schema for bulk delete operations, deconflict list (#9420)
* Add request body to schema for bulk delete operations, deconflict list vs single delete operation ids * API version bump * Fix variable name conflict * Switch from post-processing hook to AutoSchema extension * Loosen typing on filter dict, correct expected code in tests * Filter by view class instead of path --------- Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
This commit is contained in:
		| @@ -373,6 +373,20 @@ class NotFoundView(APIView): | |||||||
|         return self.not_found(request) |         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: | class BulkOperationMixin: | ||||||
|     """Mixin class for handling bulk data operations. |     """Mixin class for handling bulk data operations. | ||||||
|  |  | ||||||
| @@ -532,6 +546,7 @@ class BulkDeleteMixin(BulkOperationMixin): | |||||||
|         """ |         """ | ||||||
|         return queryset |         return queryset | ||||||
|  |  | ||||||
|  |     @extend_schema(request=BulkRequestSerializer) | ||||||
|     def delete(self, request, *args, **kwargs): |     def delete(self, request, *args, **kwargs): | ||||||
|         """Perform a DELETE operation against this list endpoint. |         """Perform a DELETE operation against this list endpoint. | ||||||
|  |  | ||||||
| @@ -553,7 +568,7 @@ class BulkDeleteMixin(BulkOperationMixin): | |||||||
|             for item in queryset: |             for item in queryset: | ||||||
|                 item.delete() |                 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): | class ListCreateDestroyAPIView(BulkDeleteMixin, ListCreateAPI): | ||||||
|   | |||||||
| @@ -1,13 +1,17 @@ | |||||||
| """InvenTree API version information.""" | """InvenTree API version information.""" | ||||||
|  |  | ||||||
| # InvenTree API version | # 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.""" | """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" | ||||||
|  |  | ||||||
|  |  | ||||||
| INVENTREE_API_TEXT = """ | 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 | v329 - 2025-03-30 : https://github.com/inventree/InvenTree/pull/9399 | ||||||
|     - Convert url path regex-specified PKs to int |     - Convert url path regex-specified PKs to int | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,10 +1,64 @@ | |||||||
| """Schema processing functions for cleaning up generated schema.""" | """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): | def postprocess_required_nullable(result, generator, request, public): | ||||||
|     """Un-require nullable fields. |     """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 |     # Process schema section | ||||||
|     schemas = result.get('components', {}).get('schemas', {}) |     schemas = result.get('components', {}).get('schemas', {}) | ||||||
|   | |||||||
| @@ -536,7 +536,7 @@ REST_FRAMEWORK = { | |||||||
|         'rest_framework.permissions.DjangoModelPermissions', |         'rest_framework.permissions.DjangoModelPermissions', | ||||||
|         'InvenTree.permissions.RolePermission', |         'InvenTree.permissions.RolePermission', | ||||||
|     ], |     ], | ||||||
|     'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', |     'DEFAULT_SCHEMA_CLASS': 'InvenTree.schema.ExtendedAutoSchema', | ||||||
|     'DEFAULT_METADATA_CLASS': 'InvenTree.metadata.InvenTreeMetadata', |     'DEFAULT_METADATA_CLASS': 'InvenTree.metadata.InvenTreeMetadata', | ||||||
|     'DEFAULT_RENDERER_CLASSES': ['rest_framework.renderers.JSONRenderer'], |     'DEFAULT_RENDERER_CLASSES': ['rest_framework.renderers.JSONRenderer'], | ||||||
|     'TOKEN_MODEL': 'users.models.ApiToken', |     'TOKEN_MODEL': 'users.models.ApiToken', | ||||||
|   | |||||||
| @@ -1112,7 +1112,7 @@ class NotificationTest(InvenTreeAPITestCase): | |||||||
|  |  | ||||||
|         # Now, let's bulk delete all 'unread' notifications via the API, |         # Now, let's bulk delete all 'unread' notifications via the API, | ||||||
|         # but only associated with the logged in user |         # 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, |         # Only 7 notifications should have been deleted, | ||||||
|         # as the notifications associated with other users must remain untouched |         # as the notifications associated with other users must remain untouched | ||||||
|   | |||||||
| @@ -756,7 +756,7 @@ class PurchaseOrderLineItemTest(OrderTest): | |||||||
|         url = reverse('api-po-line-list') |         url = reverse('api-po-line-list') | ||||||
|  |  | ||||||
|         # Try to delete a set of line items via their IDs |         # 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 |         # We should have 2 less PurchaseOrderLineItems after deletign them | ||||||
|         self.assertEqual(models.PurchaseOrderLineItem.objects.count(), n - 2) |         self.assertEqual(models.PurchaseOrderLineItem.objects.count(), n - 2) | ||||||
|   | |||||||
| @@ -2015,7 +2015,7 @@ class StockTestResultTest(StockAPITestCase): | |||||||
|  |  | ||||||
|         # Try again, but with the correct filters this time |         # Try again, but with the correct filters this time | ||||||
|         response = self.delete( |         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) |         self.assertEqual(StockItemTestResult.objects.count(), n) | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user