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:
parent
851ef71864
commit
33cc86a603
@ -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)
|
||||||
|
Loading…
x
Reference in New Issue
Block a user