2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-04-28 11:36:44 +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:
Joe Rogers 2025-04-01 01:38:17 +02:00 committed by GitHub
parent 851ef71864
commit 33cc86a603
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 80 additions and 7 deletions

View File

@ -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):

View File

@ -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

View File

@ -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', {})

View File

@ -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',

View File

@ -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

View File

@ -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)

View File

@ -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)