2
0
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:
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) 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)
queryset = self.filter_delete_queryset(queryset, request)
# Extract the items from the request body self.validate_delete(queryset, request)
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 # Keep track of how many items we deleted
n_deleted = 0 n_deleted = queryset.count()
with transaction.atomic(): with transaction.atomic():
# Start with *all* models and perform basic filtering # Perform object deletion
queryset = model.objects.all() # Note that we do not perform a bulk-delete operation here,
queryset = self.filter_delete_queryset(queryset, request) # as we want to trigger any custom post_delete methods on the model
for item in queryset:
# Filter by provided item ID values item.delete()
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()
return Response({'success': f'Deleted {n_deleted} items'}, status=204) return Response({'success': f'Deleted {n_deleted} items'}, status=204)

View File

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

View File

@ -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,7 +56,9 @@ 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'):
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): except (exceptions.APIException, PermissionDenied, Http404):
pass pass
else: else:

View File

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

View File

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

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): class DuplicatePartSerializer(serializers.Serializer):
"""Serializer for specifying options when duplicating a Part. """Serializer for specifying options when duplicating a Part.

View File

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

View File

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

View File

@ -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}
/>
]}
/> />
) )
} }

View File

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

View File

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

View File

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

View File

@ -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();
});