mirror of
https://github.com/inventree/InvenTree.git
synced 2025-04-28 11:36:44 +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:
parent
897afd029b
commit
9db5205f79
@ -373,15 +373,141 @@ class NotFoundView(APIView):
|
|||||||
return self.not_found(request)
|
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.
|
"""Mixin class for enabling 'bulk delete' operations for various models.
|
||||||
|
|
||||||
Bulk delete allows for multiple items to be deleted in a single API query,
|
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.
|
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:
|
def validate_delete(self, queryset, request) -> None:
|
||||||
@ -397,6 +523,7 @@ class BulkDeleteMixin:
|
|||||||
Raises:
|
Raises:
|
||||||
ValidationError: If the deletion should not proceed
|
ValidationError: If the deletion should not proceed
|
||||||
"""
|
"""
|
||||||
|
# Default implementation does nothing
|
||||||
|
|
||||||
def filter_delete_queryset(self, queryset, request):
|
def filter_delete_queryset(self, queryset, request):
|
||||||
"""Provide custom filtering for the queryset *before* it is deleted.
|
"""Provide custom filtering for the queryset *before* it is deleted.
|
||||||
@ -408,79 +535,23 @@ class BulkDeleteMixin:
|
|||||||
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.
|
||||||
|
|
||||||
We expect a list of primary-key (ID) values to be supplied as a JSON object, e.g.
|
Note that the typical DRF list endpoint does not support DELETE,
|
||||||
{
|
so this method is provided as a custom implementation.
|
||||||
items: [4, 8, 15, 16, 23, 42]
|
|
||||||
}
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
model = self.serializer_class.Meta.model
|
queryset = self.get_bulk_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"]
|
|
||||||
})
|
|
||||||
|
|
||||||
# Keep track of how many items we deleted
|
|
||||||
n_deleted = 0
|
|
||||||
|
|
||||||
with transaction.atomic():
|
|
||||||
# Start with *all* models and perform basic filtering
|
|
||||||
queryset = model.objects.all()
|
|
||||||
queryset = self.filter_delete_queryset(queryset, request)
|
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)
|
self.validate_delete(queryset, request)
|
||||||
|
|
||||||
|
# Keep track of how many items we deleted
|
||||||
n_deleted = queryset.count()
|
n_deleted = queryset.count()
|
||||||
queryset.delete()
|
|
||||||
|
with transaction.atomic():
|
||||||
|
# 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)
|
return Response({'success': f'Deleted {n_deleted} items'}, status=204)
|
||||||
|
|
||||||
|
@ -1,13 +1,17 @@
|
|||||||
"""InvenTree API version information."""
|
"""InvenTree API version information."""
|
||||||
|
|
||||||
# InvenTree API version
|
# 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."""
|
"""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 = """
|
||||||
|
|
||||||
|
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
|
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
|
- 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.
|
See SimpleMetadata.determine_actions for more information.
|
||||||
"""
|
"""
|
||||||
|
from InvenTree.api import BulkUpdateMixin
|
||||||
|
|
||||||
actions = {}
|
actions = {}
|
||||||
|
|
||||||
for method in {'PUT', 'POST', 'GET'} & set(view.allowed_methods):
|
for method in {'PUT', 'POST', 'GET'} & set(view.allowed_methods):
|
||||||
@ -54,6 +56,8 @@ class InvenTreeMetadata(SimpleMetadata):
|
|||||||
view.check_permissions(view.request)
|
view.check_permissions(view.request)
|
||||||
# Test object permissions
|
# Test object permissions
|
||||||
if method == 'PUT' and hasattr(view, 'get_object'):
|
if method == 'PUT' and hasattr(view, 'get_object'):
|
||||||
|
if not issubclass(view.__class__, BulkUpdateMixin):
|
||||||
|
# Bypass the get_object method for the BulkUpdateMixin
|
||||||
view.get_object()
|
view.get_object()
|
||||||
except (exceptions.APIException, PermissionDenied, Http404):
|
except (exceptions.APIException, PermissionDenied, Http404):
|
||||||
pass
|
pass
|
||||||
|
@ -217,9 +217,11 @@ class ApiAccessTests(InvenTreeAPITestCase):
|
|||||||
|
|
||||||
actions = self.getActions(url)
|
actions = self.getActions(url)
|
||||||
|
|
||||||
self.assertEqual(len(actions), 2)
|
self.assertEqual(len(actions), 3)
|
||||||
|
|
||||||
self.assertIn('POST', actions)
|
self.assertIn('POST', actions)
|
||||||
self.assertIn('GET', actions)
|
self.assertIn('GET', actions)
|
||||||
|
self.assertIn('PUT', actions) # Fancy bulk-update action
|
||||||
|
|
||||||
def test_detail_endpoint_actions(self):
|
def test_detail_endpoint_actions(self):
|
||||||
"""Tests for detail API endpoint actions."""
|
"""Tests for detail API endpoint actions."""
|
||||||
@ -268,19 +270,19 @@ class BulkDeleteTests(InvenTreeAPITestCase):
|
|||||||
response = self.delete(url, {}, expected_code=400)
|
response = self.delete(url, {}, expected_code=400)
|
||||||
|
|
||||||
self.assertIn(
|
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),
|
str(response.data),
|
||||||
)
|
)
|
||||||
|
|
||||||
# DELETE with invalid 'items'
|
# DELETE with invalid 'items'
|
||||||
response = self.delete(url, {'items': {'hello': 'world'}}, expected_code=400)
|
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'
|
# DELETE with invalid 'filters'
|
||||||
response = self.delete(url, {'filters': [1, 2, 3]}, expected_code=400)
|
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):
|
class SearchTests(InvenTreeAPITestCase):
|
||||||
|
@ -20,7 +20,7 @@ import part.filters
|
|||||||
from build.models import Build, BuildItem
|
from build.models import Build, BuildItem
|
||||||
from build.status_codes import BuildStatusGroups
|
from build.status_codes import BuildStatusGroups
|
||||||
from importer.mixins import DataExportViewMixin
|
from importer.mixins import DataExportViewMixin
|
||||||
from InvenTree.api import ListCreateDestroyAPIView, MetadataView
|
from InvenTree.api import BulkUpdateMixin, ListCreateDestroyAPIView, MetadataView
|
||||||
from InvenTree.filters import (
|
from InvenTree.filters import (
|
||||||
ORDER_FILTER,
|
ORDER_FILTER,
|
||||||
ORDER_FILTER_ALIAS,
|
ORDER_FILTER_ALIAS,
|
||||||
@ -1239,7 +1239,7 @@ class PartMixin:
|
|||||||
return context
|
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."""
|
"""API endpoint for accessing a list of Part objects, or creating a new Part instance."""
|
||||||
|
|
||||||
filterset_class = PartFilter
|
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):
|
class PartDetail(PartMixin, RetrieveUpdateDestroyAPI):
|
||||||
"""API endpoint for detail view of a single Part object."""
|
"""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('', 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'),
|
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):
|
class DuplicatePartSerializer(serializers.Serializer):
|
||||||
"""Serializer for specifying options when duplicating a Part.
|
"""Serializer for specifying options when duplicating a Part.
|
||||||
|
|
||||||
|
@ -505,7 +505,22 @@ export function ApiForm({
|
|||||||
case 400:
|
case 400:
|
||||||
// Data validation errors
|
// Data validation errors
|
||||||
const _nonFieldErrors: string[] = [];
|
const _nonFieldErrors: string[] = [];
|
||||||
|
|
||||||
const processErrors = (errors: any, _path?: string) => {
|
const processErrors = (errors: any, _path?: string) => {
|
||||||
|
// Handle an array of errors
|
||||||
|
if (Array.isArray(errors)) {
|
||||||
|
errors.forEach((error: any) => {
|
||||||
|
_nonFieldErrors.push(error.toString());
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle simple string
|
||||||
|
if (typeof errors === 'string') {
|
||||||
|
_nonFieldErrors.push(errors);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
for (const [k, v] of Object.entries(errors)) {
|
for (const [k, v] of Object.entries(errors)) {
|
||||||
const path = _path ? `${_path}.${k}` : k;
|
const path = _path ? `${_path}.${k}` : k;
|
||||||
|
|
||||||
@ -513,10 +528,8 @@ export function ApiForm({
|
|||||||
const field = fields[k];
|
const field = fields[k];
|
||||||
const valid = field && !field.hidden;
|
const valid = field && !field.hidden;
|
||||||
|
|
||||||
if (!valid || k === 'non_field_errors' || k === '__all__') {
|
if (!valid || k == 'non_field_errors' || k == '__all__') {
|
||||||
if (Array.isArray(v)) {
|
processErrors(v);
|
||||||
_nonFieldErrors.push(...v);
|
|
||||||
}
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -88,7 +88,7 @@ export function useCreateApiFormModal(props: ApiFormModalProps) {
|
|||||||
props.successMessage === null
|
props.successMessage === null
|
||||||
? null
|
? null
|
||||||
: (props.successMessage ?? t`Item Created`),
|
: (props.successMessage ?? t`Item Created`),
|
||||||
method: 'POST'
|
method: props.method ?? 'POST'
|
||||||
}),
|
}),
|
||||||
[props]
|
[props]
|
||||||
);
|
);
|
||||||
@ -116,6 +116,41 @@ export function useEditApiFormModal(props: ApiFormModalProps) {
|
|||||||
return useApiFormModal(editProps);
|
return useApiFormModal(editProps);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface BulkEditApiFormModalProps extends ApiFormModalProps {
|
||||||
|
items: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useBulkEditApiFormModal({
|
||||||
|
items,
|
||||||
|
...props
|
||||||
|
}: BulkEditApiFormModalProps) {
|
||||||
|
const bulkEditProps = useMemo<ApiFormModalProps>(
|
||||||
|
() => ({
|
||||||
|
...props,
|
||||||
|
method: 'PATCH',
|
||||||
|
submitText: props.submitText ?? t`Update`,
|
||||||
|
successMessage:
|
||||||
|
props.successMessage === null
|
||||||
|
? null
|
||||||
|
: (props.successMessage ?? t`Items Updated`),
|
||||||
|
preFormContent: props.preFormContent ?? (
|
||||||
|
<Alert color={'blue'}>{t`Update multiple items`}</Alert>
|
||||||
|
),
|
||||||
|
fields: {
|
||||||
|
...props.fields,
|
||||||
|
items: {
|
||||||
|
hidden: true,
|
||||||
|
field_type: 'number',
|
||||||
|
value: items
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
[props, items]
|
||||||
|
);
|
||||||
|
|
||||||
|
return useApiFormModal(bulkEditProps);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Open a modal form to delete a model instance
|
* Open a modal form to delete a model instance
|
||||||
*/
|
*/
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import { Stack } from '@mantine/core';
|
import { Stack } from '@mantine/core';
|
||||||
import { modals } from '@mantine/modals';
|
|
||||||
import {
|
import {
|
||||||
IconBellCheck,
|
IconBellCheck,
|
||||||
IconBellExclamation,
|
IconBellExclamation,
|
||||||
@ -39,26 +38,6 @@ export default function NotificationsPage() {
|
|||||||
.catch((_error) => {});
|
.catch((_error) => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const deleteNotifications = useCallback(() => {
|
|
||||||
modals.openConfirmModal({
|
|
||||||
title: t`Delete Notifications`,
|
|
||||||
onConfirm: () => {
|
|
||||||
api
|
|
||||||
.delete(apiUrl(ApiEndpoints.notifications_list), {
|
|
||||||
data: {
|
|
||||||
filters: {
|
|
||||||
read: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.then((_response) => {
|
|
||||||
readTable.refreshTable();
|
|
||||||
})
|
|
||||||
.catch((_error) => {});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const notificationPanels = useMemo(() => {
|
const notificationPanels = useMemo(() => {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@ -139,14 +118,7 @@ export default function NotificationsPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
]}
|
]}
|
||||||
tableActions={[
|
tableActions={[]}
|
||||||
<ActionButton
|
|
||||||
color='red'
|
|
||||||
icon={<IconTrash />}
|
|
||||||
tooltip={t`Delete notifications`}
|
|
||||||
onClick={deleteNotifications}
|
|
||||||
/>
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -9,6 +9,7 @@ import {
|
|||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import {
|
import {
|
||||||
IconBarcode,
|
IconBarcode,
|
||||||
|
IconExclamationCircle,
|
||||||
IconFilter,
|
IconFilter,
|
||||||
IconRefresh,
|
IconRefresh,
|
||||||
IconTrash
|
IconTrash
|
||||||
@ -16,6 +17,7 @@ import {
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { Fragment } from 'react/jsx-runtime';
|
import { Fragment } from 'react/jsx-runtime';
|
||||||
|
|
||||||
|
import { showNotification } from '@mantine/notifications';
|
||||||
import { Boundary } from '../components/Boundary';
|
import { Boundary } from '../components/Boundary';
|
||||||
import { ActionButton } from '../components/buttons/ActionButton';
|
import { ActionButton } from '../components/buttons/ActionButton';
|
||||||
import { ButtonMenu } from '../components/buttons/ButtonMenu';
|
import { ButtonMenu } from '../components/buttons/ButtonMenu';
|
||||||
@ -112,6 +114,17 @@ export default function InvenTreeTableHeader({
|
|||||||
hidden: true
|
hidden: true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
successMessage: t`Items deleted`,
|
||||||
|
onFormError: (response) => {
|
||||||
|
showNotification({
|
||||||
|
id: 'bulk-delete-error',
|
||||||
|
title: t`Error`,
|
||||||
|
message: t`Failed to delete items`,
|
||||||
|
color: 'red',
|
||||||
|
icon: <IconExclamationCircle />,
|
||||||
|
autoClose: 5000
|
||||||
|
});
|
||||||
|
},
|
||||||
onFormSuccess: () => {
|
onFormSuccess: () => {
|
||||||
tableState.clearSelectedRecords();
|
tableState.clearSelectedRecords();
|
||||||
tableState.refreshTable();
|
tableState.refreshTable();
|
||||||
|
@ -52,6 +52,7 @@ export function NotificationTable({
|
|||||||
rowActions: actions,
|
rowActions: actions,
|
||||||
tableActions: tableActions,
|
tableActions: tableActions,
|
||||||
enableSelection: true,
|
enableSelection: true,
|
||||||
|
enableBulkDelete: true,
|
||||||
params: params
|
params: params
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
@ -12,7 +12,10 @@ import { ModelType } from '../../enums/ModelType';
|
|||||||
import { UserRoles } from '../../enums/Roles';
|
import { UserRoles } from '../../enums/Roles';
|
||||||
import { usePartFields } from '../../forms/PartForms';
|
import { usePartFields } from '../../forms/PartForms';
|
||||||
import { InvenTreeIcon } from '../../functions/icons';
|
import { InvenTreeIcon } from '../../functions/icons';
|
||||||
import { useCreateApiFormModal } from '../../hooks/UseForm';
|
import {
|
||||||
|
useBulkEditApiFormModal,
|
||||||
|
useCreateApiFormModal
|
||||||
|
} from '../../hooks/UseForm';
|
||||||
import { useTable } from '../../hooks/UseTable';
|
import { useTable } from '../../hooks/UseTable';
|
||||||
import { apiUrl } from '../../states/ApiState';
|
import { apiUrl } from '../../states/ApiState';
|
||||||
import { useUserState } from '../../states/UserState';
|
import { useUserState } from '../../states/UserState';
|
||||||
@ -341,6 +344,16 @@ export function PartListTable({
|
|||||||
modelType: ModelType.part
|
modelType: ModelType.part
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const setCategory = useBulkEditApiFormModal({
|
||||||
|
url: ApiEndpoints.part_list,
|
||||||
|
items: table.selectedIds,
|
||||||
|
title: t`Set Category`,
|
||||||
|
fields: {
|
||||||
|
category: {}
|
||||||
|
},
|
||||||
|
onFormSuccess: table.refreshTable
|
||||||
|
});
|
||||||
|
|
||||||
const orderPartsWizard = OrderPartsWizard({ parts: table.selectedRecords });
|
const orderPartsWizard = OrderPartsWizard({ parts: table.selectedRecords });
|
||||||
|
|
||||||
const tableActions = useMemo(() => {
|
const tableActions = useMemo(() => {
|
||||||
@ -350,10 +363,21 @@ export function PartListTable({
|
|||||||
icon={<InvenTreeIcon icon='part' />}
|
icon={<InvenTreeIcon icon='part' />}
|
||||||
disabled={!table.hasSelectedRecords}
|
disabled={!table.hasSelectedRecords}
|
||||||
actions={[
|
actions={[
|
||||||
|
{
|
||||||
|
name: t`Set Category`,
|
||||||
|
icon: <InvenTreeIcon icon='category' />,
|
||||||
|
tooltip: t`Set category for selected parts`,
|
||||||
|
hidden: !user.hasChangeRole(UserRoles.part),
|
||||||
|
disabled: !table.hasSelectedRecords,
|
||||||
|
onClick: () => {
|
||||||
|
setCategory.open();
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: t`Order Parts`,
|
name: t`Order Parts`,
|
||||||
icon: <IconShoppingCart color='blue' />,
|
icon: <IconShoppingCart color='blue' />,
|
||||||
tooltip: t`Order selected parts`,
|
tooltip: t`Order selected parts`,
|
||||||
|
hidden: !user.hasAddRole(UserRoles.purchase_order),
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
orderPartsWizard.openWizard();
|
orderPartsWizard.openWizard();
|
||||||
}
|
}
|
||||||
@ -372,6 +396,7 @@ export function PartListTable({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{newPart.modal}
|
{newPart.modal}
|
||||||
|
{setCategory.modal}
|
||||||
{orderPartsWizard.wizard}
|
{orderPartsWizard.wizard}
|
||||||
<InvenTreeTable
|
<InvenTreeTable
|
||||||
url={apiUrl(ApiEndpoints.part_list)}
|
url={apiUrl(ApiEndpoints.part_list)}
|
||||||
|
@ -419,3 +419,23 @@ test('Parts - Revision', async ({ page }) => {
|
|||||||
await page.waitForURL('**/platform/part/101/**');
|
await page.waitForURL('**/platform/part/101/**');
|
||||||
await page.getByText('Select Part Revision').waitFor();
|
await page.getByText('Select Part Revision').waitFor();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Parts - Bulk Edit', async ({ page }) => {
|
||||||
|
await doQuickLogin(page);
|
||||||
|
|
||||||
|
await navigate(page, 'part/category/index/parts');
|
||||||
|
|
||||||
|
// Edit the category for multiple parts
|
||||||
|
await page.getByLabel('Select record 1', { exact: true }).click();
|
||||||
|
await page.getByLabel('Select record 2', { exact: true }).click();
|
||||||
|
await page.getByLabel('action-menu-part-actions').click();
|
||||||
|
await page.getByLabel('action-menu-part-actions-set-category').click();
|
||||||
|
await page.getByLabel('related-field-category').fill('rnitu');
|
||||||
|
await page
|
||||||
|
.getByRole('option', { name: '- Furniture/Chairs' })
|
||||||
|
.getByRole('paragraph')
|
||||||
|
.click();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Update' }).click();
|
||||||
|
await page.getByText('Items Updated').waitFor();
|
||||||
|
});
|
||||||
|
Loading…
x
Reference in New Issue
Block a user