2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-04-28 03:26:45 +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:
Oliver 2025-03-17 09:21:43 +11:00 committed by GitHub
parent 897afd029b
commit 9db5205f79
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 275 additions and 178 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -505,7 +505,22 @@ export function ApiForm({
case 400:
// Data validation errors
const _nonFieldErrors: 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)) {
const path = _path ? `${_path}.${k}` : k;
@ -513,10 +528,8 @@ export function ApiForm({
const field = fields[k];
const valid = field && !field.hidden;
if (!valid || k === 'non_field_errors' || k === '__all__') {
if (Array.isArray(v)) {
_nonFieldErrors.push(...v);
}
if (!valid || k == 'non_field_errors' || k == '__all__') {
processErrors(v);
continue;
}

View File

@ -88,7 +88,7 @@ export function useCreateApiFormModal(props: ApiFormModalProps) {
props.successMessage === null
? null
: (props.successMessage ?? t`Item Created`),
method: 'POST'
method: props.method ?? 'POST'
}),
[props]
);
@ -116,6 +116,41 @@ export function useEditApiFormModal(props: ApiFormModalProps) {
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
*/

View File

@ -1,6 +1,5 @@
import { t } from '@lingui/macro';
import { Stack } from '@mantine/core';
import { modals } from '@mantine/modals';
import {
IconBellCheck,
IconBellExclamation,
@ -39,26 +38,6 @@ export default function NotificationsPage() {
.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(() => {
return [
{
@ -139,14 +118,7 @@ export default function NotificationsPage() {
}
}
]}
tableActions={[
<ActionButton
color='red'
icon={<IconTrash />}
tooltip={t`Delete notifications`}
onClick={deleteNotifications}
/>
]}
tableActions={[]}
/>
)
}

View File

@ -9,6 +9,7 @@ import {
} from '@mantine/core';
import {
IconBarcode,
IconExclamationCircle,
IconFilter,
IconRefresh,
IconTrash
@ -16,6 +17,7 @@ import {
import { useMemo, useState } from 'react';
import { Fragment } from 'react/jsx-runtime';
import { showNotification } from '@mantine/notifications';
import { Boundary } from '../components/Boundary';
import { ActionButton } from '../components/buttons/ActionButton';
import { ButtonMenu } from '../components/buttons/ButtonMenu';
@ -112,6 +114,17 @@ export default function InvenTreeTableHeader({
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: () => {
tableState.clearSelectedRecords();
tableState.refreshTable();

View File

@ -52,6 +52,7 @@ export function NotificationTable({
rowActions: actions,
tableActions: tableActions,
enableSelection: true,
enableBulkDelete: true,
params: params
}}
/>

View File

@ -12,7 +12,10 @@ import { ModelType } from '../../enums/ModelType';
import { UserRoles } from '../../enums/Roles';
import { usePartFields } from '../../forms/PartForms';
import { InvenTreeIcon } from '../../functions/icons';
import { useCreateApiFormModal } from '../../hooks/UseForm';
import {
useBulkEditApiFormModal,
useCreateApiFormModal
} from '../../hooks/UseForm';
import { useTable } from '../../hooks/UseTable';
import { apiUrl } from '../../states/ApiState';
import { useUserState } from '../../states/UserState';
@ -341,6 +344,16 @@ export function PartListTable({
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 tableActions = useMemo(() => {
@ -350,10 +363,21 @@ export function PartListTable({
icon={<InvenTreeIcon icon='part' />}
disabled={!table.hasSelectedRecords}
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`,
icon: <IconShoppingCart color='blue' />,
tooltip: t`Order selected parts`,
hidden: !user.hasAddRole(UserRoles.purchase_order),
onClick: () => {
orderPartsWizard.openWizard();
}
@ -372,6 +396,7 @@ export function PartListTable({
return (
<>
{newPart.modal}
{setCategory.modal}
{orderPartsWizard.wizard}
<InvenTreeTable
url={apiUrl(ApiEndpoints.part_list)}

View File

@ -419,3 +419,23 @@ test('Parts - Revision', async ({ page }) => {
await page.waitForURL('**/platform/part/101/**');
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();
});