mirror of
https://github.com/inventree/InvenTree.git
synced 2025-06-16 12:05:53 +00:00
Bulk update mixin (#9313)
* Refactor BulkDeleteMixin * Implement BulkUpdateMixin class * Refactor NotificationsTable - Use common bulkdelete operation * Update successMessage * Update metadata constructs * Add bulk-edit support for PartList endpoint * Implement set-category for part table * Cleanup old endpoint * Improve form error handling * Simplify translated text * Add playwright tests * Bump API version * Fix unit tests * Further test updates
This commit is contained in:
@ -373,15 +373,141 @@ class NotFoundView(APIView):
|
||||
return self.not_found(request)
|
||||
|
||||
|
||||
class BulkDeleteMixin:
|
||||
class BulkOperationMixin:
|
||||
"""Mixin class for handling bulk data operations.
|
||||
|
||||
Bulk operations are implemented for two major reasons:
|
||||
- Speed (single API call vs multiple API calls)
|
||||
- Atomicity (guaranteed that either *all* items are updated, or *none*)
|
||||
"""
|
||||
|
||||
def get_bulk_queryset(self, request):
|
||||
"""Return a queryset based on the selection made in the request.
|
||||
|
||||
Selection can be made by providing either:
|
||||
|
||||
- items: A list of primary key values
|
||||
- filters: A dictionary of filter values
|
||||
"""
|
||||
model = self.serializer_class.Meta.model
|
||||
|
||||
items = request.data.pop('items', None)
|
||||
filters = request.data.pop('filters', None)
|
||||
|
||||
queryset = model.objects.all()
|
||||
|
||||
if not items and not filters:
|
||||
raise ValidationError({
|
||||
'non_field_errors': _(
|
||||
'List of items or filters must be provided for bulk operation'
|
||||
)
|
||||
})
|
||||
|
||||
if items:
|
||||
if type(items) is not list:
|
||||
raise ValidationError({
|
||||
'non_field_errors': _('Items must be provided as a list')
|
||||
})
|
||||
|
||||
# Filter by primary key
|
||||
try:
|
||||
queryset = queryset.filter(pk__in=items)
|
||||
except Exception:
|
||||
raise ValidationError({
|
||||
'non_field_errors': _('Invalid items list provided')
|
||||
})
|
||||
|
||||
if filters:
|
||||
if type(filters) is not dict:
|
||||
raise ValidationError({
|
||||
'non_field_errors': _('Filters must be provided as a dict')
|
||||
})
|
||||
|
||||
try:
|
||||
queryset = queryset.filter(**filters)
|
||||
except Exception:
|
||||
raise ValidationError({
|
||||
'non_field_errors': _('Invalid filters provided')
|
||||
})
|
||||
|
||||
if queryset.count() == 0:
|
||||
raise ValidationError({
|
||||
'non_field_errors': _('No items match the provided criteria')
|
||||
})
|
||||
|
||||
return queryset
|
||||
|
||||
|
||||
class BulkUpdateMixin(BulkOperationMixin):
|
||||
"""Mixin class for enabling 'bulk update' operations for various models.
|
||||
|
||||
Bulk update allows for multiple items to be updated in a single API query,
|
||||
rather than using multiple API calls to the various detail endpoints.
|
||||
"""
|
||||
|
||||
def validate_update(self, queryset, request) -> None:
|
||||
"""Perform validation right before updating.
|
||||
|
||||
Arguments:
|
||||
queryset: The queryset to be updated
|
||||
request: The request object
|
||||
|
||||
Returns:
|
||||
None
|
||||
|
||||
Raises:
|
||||
ValidationError: If the update should not proceed
|
||||
"""
|
||||
# Default implementation does nothing
|
||||
|
||||
def filter_update_queryset(self, queryset, request):
|
||||
"""Provide custom filtering for the queryset *before* it is updated.
|
||||
|
||||
The default implementation does nothing, just returns the queryset.
|
||||
"""
|
||||
return queryset
|
||||
|
||||
def put(self, request, *args, **kwargs):
|
||||
"""Perform a PUT operation against this list endpoint.
|
||||
|
||||
Simply redirects to the PATCH method.
|
||||
"""
|
||||
return self.patch(request, *args, **kwargs)
|
||||
|
||||
def patch(self, request, *args, **kwargs):
|
||||
"""Perform a PATCH operation against this list endpoint.
|
||||
|
||||
Note that the typical DRF list endpoint does not support PATCH,
|
||||
so this method is provided as a custom implementation.
|
||||
"""
|
||||
queryset = self.get_bulk_queryset(request)
|
||||
queryset = self.filter_update_queryset(queryset, request)
|
||||
|
||||
self.validate_update(queryset, request)
|
||||
|
||||
# Perform the update operation
|
||||
data = request.data
|
||||
|
||||
n = queryset.count()
|
||||
|
||||
with transaction.atomic():
|
||||
# Perform object update
|
||||
# Note that we do not perform a bulk-update operation here,
|
||||
# as we want to trigger any custom post_save methods on the model
|
||||
for instance in queryset:
|
||||
serializer = self.get_serializer(instance, data=data, partial=True)
|
||||
|
||||
serializer.is_valid(raise_exception=True)
|
||||
serializer.save()
|
||||
|
||||
return Response({'success': f'Updated {n} items'}, status=200)
|
||||
|
||||
|
||||
class BulkDeleteMixin(BulkOperationMixin):
|
||||
"""Mixin class for enabling 'bulk delete' operations for various models.
|
||||
|
||||
Bulk delete allows for multiple items to be deleted in a single API query,
|
||||
rather than using multiple API calls to the various detail endpoints.
|
||||
|
||||
This is implemented for two major reasons:
|
||||
- Atomicity (guaranteed that either *all* items are deleted, or *none*)
|
||||
- Speed (single API call and DB query)
|
||||
"""
|
||||
|
||||
def validate_delete(self, queryset, request) -> None:
|
||||
@ -397,6 +523,7 @@ class BulkDeleteMixin:
|
||||
Raises:
|
||||
ValidationError: If the deletion should not proceed
|
||||
"""
|
||||
# Default implementation does nothing
|
||||
|
||||
def filter_delete_queryset(self, queryset, request):
|
||||
"""Provide custom filtering for the queryset *before* it is deleted.
|
||||
@ -408,79 +535,23 @@ class BulkDeleteMixin:
|
||||
def delete(self, request, *args, **kwargs):
|
||||
"""Perform a DELETE operation against this list endpoint.
|
||||
|
||||
We expect a list of primary-key (ID) values to be supplied as a JSON object, e.g.
|
||||
{
|
||||
items: [4, 8, 15, 16, 23, 42]
|
||||
}
|
||||
|
||||
Note that the typical DRF list endpoint does not support DELETE,
|
||||
so this method is provided as a custom implementation.
|
||||
"""
|
||||
model = self.serializer_class.Meta.model
|
||||
queryset = self.get_bulk_queryset(request)
|
||||
queryset = self.filter_delete_queryset(queryset, request)
|
||||
|
||||
# Extract the items from the request body
|
||||
try:
|
||||
items = request.data.getlist('items', None)
|
||||
except AttributeError:
|
||||
items = request.data.get('items', None)
|
||||
|
||||
# Extract the filters from the request body
|
||||
try:
|
||||
filters = request.data.getlist('filters', None)
|
||||
except AttributeError:
|
||||
filters = request.data.get('filters', None)
|
||||
|
||||
if not items and not filters:
|
||||
raise ValidationError({
|
||||
'non_field_errors': [
|
||||
'List of items or filters must be provided for bulk deletion'
|
||||
]
|
||||
})
|
||||
|
||||
if items and type(items) is not list:
|
||||
raise ValidationError({
|
||||
'items': ["'items' must be supplied as a list object"]
|
||||
})
|
||||
|
||||
if filters and type(filters) is not dict:
|
||||
raise ValidationError({
|
||||
'filters': ["'filters' must be supplied as a dict object"]
|
||||
})
|
||||
self.validate_delete(queryset, request)
|
||||
|
||||
# Keep track of how many items we deleted
|
||||
n_deleted = 0
|
||||
n_deleted = queryset.count()
|
||||
|
||||
with transaction.atomic():
|
||||
# Start with *all* models and perform basic filtering
|
||||
queryset = model.objects.all()
|
||||
queryset = self.filter_delete_queryset(queryset, request)
|
||||
|
||||
# Filter by provided item ID values
|
||||
if items:
|
||||
try:
|
||||
queryset = queryset.filter(id__in=items)
|
||||
except Exception:
|
||||
raise ValidationError({
|
||||
'non_field_errors': _('Invalid items list provided')
|
||||
})
|
||||
|
||||
# Filter by provided filters
|
||||
if filters:
|
||||
try:
|
||||
queryset = queryset.filter(**filters)
|
||||
except Exception:
|
||||
raise ValidationError({
|
||||
'non_field_errors': _('Invalid filters provided')
|
||||
})
|
||||
|
||||
if queryset.count() == 0:
|
||||
raise ValidationError({
|
||||
'non_field_errors': _('No items found to delete')
|
||||
})
|
||||
|
||||
# Run a final validation step (should raise an error if the deletion should not proceed)
|
||||
self.validate_delete(queryset, request)
|
||||
|
||||
n_deleted = queryset.count()
|
||||
queryset.delete()
|
||||
# Perform object deletion
|
||||
# Note that we do not perform a bulk-delete operation here,
|
||||
# as we want to trigger any custom post_delete methods on the model
|
||||
for item in queryset:
|
||||
item.delete()
|
||||
|
||||
return Response({'success': f'Deleted {n_deleted} items'}, status=204)
|
||||
|
||||
|
@ -1,13 +1,17 @@
|
||||
"""InvenTree API version information."""
|
||||
|
||||
# InvenTree API version
|
||||
INVENTREE_API_VERSION = 322
|
||||
INVENTREE_API_VERSION = 323
|
||||
|
||||
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||
|
||||
|
||||
INVENTREE_API_TEXT = """
|
||||
|
||||
v323 - 2025-03-17 : https://github.com/inventree/InvenTree/pull/9313
|
||||
- Adds BulkUpdate support to the Part API endpoint
|
||||
- Remove legacy API endpoint to set part category for multiple parts
|
||||
|
||||
v322 - 2025-03-16 : https://github.com/inventree/InvenTree/pull/8933
|
||||
- Add min_date and max_date query filters for orders, for use in calendar views
|
||||
|
||||
|
@ -44,6 +44,8 @@ class InvenTreeMetadata(SimpleMetadata):
|
||||
|
||||
See SimpleMetadata.determine_actions for more information.
|
||||
"""
|
||||
from InvenTree.api import BulkUpdateMixin
|
||||
|
||||
actions = {}
|
||||
|
||||
for method in {'PUT', 'POST', 'GET'} & set(view.allowed_methods):
|
||||
@ -54,7 +56,9 @@ class InvenTreeMetadata(SimpleMetadata):
|
||||
view.check_permissions(view.request)
|
||||
# Test object permissions
|
||||
if method == 'PUT' and hasattr(view, 'get_object'):
|
||||
view.get_object()
|
||||
if not issubclass(view.__class__, BulkUpdateMixin):
|
||||
# Bypass the get_object method for the BulkUpdateMixin
|
||||
view.get_object()
|
||||
except (exceptions.APIException, PermissionDenied, Http404):
|
||||
pass
|
||||
else:
|
||||
|
@ -217,9 +217,11 @@ class ApiAccessTests(InvenTreeAPITestCase):
|
||||
|
||||
actions = self.getActions(url)
|
||||
|
||||
self.assertEqual(len(actions), 2)
|
||||
self.assertEqual(len(actions), 3)
|
||||
|
||||
self.assertIn('POST', actions)
|
||||
self.assertIn('GET', actions)
|
||||
self.assertIn('PUT', actions) # Fancy bulk-update action
|
||||
|
||||
def test_detail_endpoint_actions(self):
|
||||
"""Tests for detail API endpoint actions."""
|
||||
@ -268,19 +270,19 @@ class BulkDeleteTests(InvenTreeAPITestCase):
|
||||
response = self.delete(url, {}, expected_code=400)
|
||||
|
||||
self.assertIn(
|
||||
'List of items or filters must be provided for bulk deletion',
|
||||
'List of items or filters must be provided for bulk operation',
|
||||
str(response.data),
|
||||
)
|
||||
|
||||
# DELETE with invalid 'items'
|
||||
response = self.delete(url, {'items': {'hello': 'world'}}, expected_code=400)
|
||||
|
||||
self.assertIn("'items' must be supplied as a list object", str(response.data))
|
||||
self.assertIn('Items must be provided as a list', str(response.data))
|
||||
|
||||
# DELETE with invalid 'filters'
|
||||
response = self.delete(url, {'filters': [1, 2, 3]}, expected_code=400)
|
||||
|
||||
self.assertIn("'filters' must be supplied as a dict object", str(response.data))
|
||||
self.assertIn('Filters must be provided as a dict', str(response.data))
|
||||
|
||||
|
||||
class SearchTests(InvenTreeAPITestCase):
|
||||
|
@ -20,7 +20,7 @@ import part.filters
|
||||
from build.models import Build, BuildItem
|
||||
from build.status_codes import BuildStatusGroups
|
||||
from importer.mixins import DataExportViewMixin
|
||||
from InvenTree.api import ListCreateDestroyAPIView, MetadataView
|
||||
from InvenTree.api import BulkUpdateMixin, ListCreateDestroyAPIView, MetadataView
|
||||
from InvenTree.filters import (
|
||||
ORDER_FILTER,
|
||||
ORDER_FILTER_ALIAS,
|
||||
@ -1239,7 +1239,7 @@ class PartMixin:
|
||||
return context
|
||||
|
||||
|
||||
class PartList(PartMixin, DataExportViewMixin, ListCreateAPI):
|
||||
class PartList(PartMixin, BulkUpdateMixin, DataExportViewMixin, ListCreateAPI):
|
||||
"""API endpoint for accessing a list of Part objects, or creating a new Part instance."""
|
||||
|
||||
filterset_class = PartFilter
|
||||
@ -1407,13 +1407,6 @@ class PartList(PartMixin, DataExportViewMixin, ListCreateAPI):
|
||||
]
|
||||
|
||||
|
||||
class PartChangeCategory(CreateAPI):
|
||||
"""API endpoint to change the location of multiple parts in bulk."""
|
||||
|
||||
serializer_class = part_serializers.PartSetCategorySerializer
|
||||
queryset = Part.objects.none()
|
||||
|
||||
|
||||
class PartDetail(PartMixin, RetrieveUpdateDestroyAPI):
|
||||
"""API endpoint for detail view of a single Part object."""
|
||||
|
||||
@ -2231,11 +2224,6 @@ part_api_urls = [
|
||||
path('', PartDetail.as_view(), name='api-part-detail'),
|
||||
]),
|
||||
),
|
||||
path(
|
||||
'change_category/',
|
||||
PartChangeCategory.as_view(),
|
||||
name='api-part-change-category',
|
||||
),
|
||||
path('', PartList.as_view(), name='api-part-list'),
|
||||
]
|
||||
|
||||
|
@ -465,57 +465,6 @@ class PartParameterSerializer(
|
||||
)
|
||||
|
||||
|
||||
class PartSetCategorySerializer(serializers.Serializer):
|
||||
"""Serializer for changing PartCategory for multiple Part objects."""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
|
||||
fields = ['parts', 'category']
|
||||
|
||||
parts = serializers.PrimaryKeyRelatedField(
|
||||
queryset=Part.objects.all(),
|
||||
many=True,
|
||||
required=True,
|
||||
allow_null=False,
|
||||
label=_('Parts'),
|
||||
)
|
||||
|
||||
def validate_parts(self, parts):
|
||||
"""Validate the selected parts."""
|
||||
if len(parts) == 0:
|
||||
raise serializers.ValidationError(_('No parts selected'))
|
||||
|
||||
return parts
|
||||
|
||||
category = serializers.PrimaryKeyRelatedField(
|
||||
queryset=PartCategory.objects.filter(structural=False),
|
||||
many=False,
|
||||
required=True,
|
||||
allow_null=False,
|
||||
label=_('Category'),
|
||||
help_text=_('Select category'),
|
||||
)
|
||||
|
||||
@transaction.atomic
|
||||
def save(self):
|
||||
"""Save the serializer to change the location of the selected parts."""
|
||||
data = self.validated_data
|
||||
parts = data['parts']
|
||||
category = data['category']
|
||||
|
||||
parts_to_save = []
|
||||
|
||||
for p in parts:
|
||||
if p.category == category:
|
||||
continue
|
||||
|
||||
p.category = category
|
||||
parts_to_save.append(p)
|
||||
|
||||
Part.objects.bulk_update(parts_to_save, ['category'])
|
||||
|
||||
|
||||
class DuplicatePartSerializer(serializers.Serializer):
|
||||
"""Serializer for specifying options when duplicating a Part.
|
||||
|
||||
|
Reference in New Issue
Block a user