Bulk delete line items (#12391)

* Add bulk-delete mixin for order line items

* Add unit tests

* Enable frontend support

* Add CHANGELOG entry

* Add locking control for TransferOrder

* Fix playwright tests

* Fix playwright tests
This commit is contained in:
Oliver
2026-07-16 11:55:34 +10:00
committed by GitHub
parent 8407f63656
commit 3fd0e9d304
17 changed files with 252 additions and 23 deletions
@@ -1,11 +1,16 @@
"""InvenTree API version information."""
# InvenTree API version
INVENTREE_API_VERSION = 522
INVENTREE_API_VERSION = 523
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
v523 -> 2026-07-14 : https://github.com/inventree/InvenTree/pull/12391
- Adds "bulk delete" support for order line item API endpoints (PurchaseOrder / SalesOrder / ReturnOrder / TransferOrder)
- Adds "bulk delete" support for order extra line item API endpoints
- Completed TransferOrder objects are now "locked" (controlled by the new TRANSFERORDER_EDIT_COMPLETED_ORDERS global setting)
v522 -> 2026-07-14 : https://github.com/inventree/InvenTree/pull/12388
- Adds "unique" field to the ParameterTemplate model
@@ -951,6 +951,14 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
'default': False,
'validator': bool,
},
'TRANSFERORDER_EDIT_COMPLETED_ORDERS': {
'name': _('Edit Completed Transfer Orders'),
'description': _(
'Allow editing of transfer orders after they have been completed'
),
'default': False,
'validator': bool,
},
'SALESORDER_BLOCK_INCOMPLETE_ITEM_TESTS': {
'name': _('Block Incomplete Item Tests'),
'description': _(
+19 -6
View File
@@ -770,7 +770,7 @@ class PurchaseOrderLineItemDetail(
class PurchaseOrderExtraLineList(
GeneralExtraLineList, OutputOptionsMixin, ListCreateAPI
GeneralExtraLineList, OutputOptionsMixin, ListCreateDestroyAPIView
):
"""API endpoint for accessing a list of PurchaseOrderExtraLine objects."""
@@ -1062,7 +1062,10 @@ class SalesOrderLineItemOutputOptions(OutputConfiguration):
class SalesOrderLineItemList(
SalesOrderLineItemMixin, DataExportViewMixin, OutputOptionsMixin, ListCreateAPI
SalesOrderLineItemMixin,
DataExportViewMixin,
OutputOptionsMixin,
ListCreateDestroyAPIView,
):
"""API endpoint for accessing a list of SalesOrderLineItem objects."""
@@ -1106,7 +1109,9 @@ class SalesOrderLineItemDetail(
output_options = SalesOrderLineItemOutputOptions
class SalesOrderExtraLineList(GeneralExtraLineList, OutputOptionsMixin, ListCreateAPI):
class SalesOrderExtraLineList(
GeneralExtraLineList, OutputOptionsMixin, ListCreateDestroyAPIView
):
"""API endpoint for accessing a list of SalesOrderExtraLine objects."""
queryset = models.SalesOrderExtraLine.objects.all()
@@ -1776,7 +1781,10 @@ class ReturnOrderLineItemOutputOptions(OutputConfiguration):
class ReturnOrderLineItemList(
ReturnOrderLineItemMixin, DataExportViewMixin, OutputOptionsMixin, ListCreateAPI
ReturnOrderLineItemMixin,
DataExportViewMixin,
OutputOptionsMixin,
ListCreateDestroyAPIView,
):
"""API endpoint for accessing a list of ReturnOrderLineItemList objects."""
@@ -1819,7 +1827,9 @@ class ReturnOrderLineItemDetail(
output_options = ReturnOrderLineItemOutputOptions
class ReturnOrderExtraLineList(GeneralExtraLineList, OutputOptionsMixin, ListCreateAPI):
class ReturnOrderExtraLineList(
GeneralExtraLineList, OutputOptionsMixin, ListCreateDestroyAPIView
):
"""API endpoint for accessing a list of ReturnOrderExtraLine objects."""
queryset = models.ReturnOrderExtraLine.objects.all()
@@ -2314,7 +2324,10 @@ class TransferOrderLineItemOutputOptions(OutputConfiguration):
class TransferOrderLineItemList(
TransferOrderLineItemMixin, DataExportViewMixin, OutputOptionsMixin, ListCreateAPI
TransferOrderLineItemMixin,
DataExportViewMixin,
OutputOptionsMixin,
ListCreateDestroyAPIView,
):
"""API endpoint for accessing a list of TransferOrderLineItem objects."""
+1 -1
View File
@@ -3478,7 +3478,7 @@ class TransferOrder(Order):
REFERENCE_PATTERN_SETTING = 'TRANSFERORDER_REFERENCE_PATTERN'
REQUIRE_RESPONSIBLE_SETTING = 'TRANSFERORDER_REQUIRE_RESPONSIBLE'
STATUS_CLASS = TransferOrderStatus
# UNLOCK_SETTING = 'TRANSFERORDER_EDIT_COMPLETED_ORDERS'
UNLOCK_SETTING = 'TRANSFERORDER_EDIT_COMPLETED_ORDERS'
class Meta:
"""Model meta options."""
+179 -2
View File
@@ -900,16 +900,46 @@ class PurchaseOrderLineItemTest(OrderTest):
"""Test that we can bulk delete multiple PurchaseOrderLineItems via the API."""
n = models.PurchaseOrderLineItem.objects.count()
self.assignRole('purchase_order.delete')
url = reverse('api-po-line-list')
# Deletion should fail without the correct role
self.delete(url, {'items': [1, 2]}, expected_code=403)
self.assignRole('purchase_order.delete')
# Try to delete a set of line items via their IDs
self.delete(url, {'items': [1, 2]}, expected_code=200)
# We should have 2 less PurchaseOrderLineItems after deleting them
self.assertEqual(models.PurchaseOrderLineItem.objects.count(), n - 2)
def test_po_extra_line_bulk_delete(self):
"""Test that we can bulk delete multiple PurchaseOrderExtraLine items via the API."""
po = models.PurchaseOrder.objects.get(pk=1)
models.PurchaseOrderExtraLine.objects.bulk_create([
models.PurchaseOrderExtraLine(
order=po, quantity=idx + 1, reference=f'Extra line {idx}'
)
for idx in range(3)
])
n = models.PurchaseOrderExtraLine.objects.count()
items = list(
models.PurchaseOrderExtraLine.objects.values_list('pk', flat=True)[:2]
)
url = reverse('api-po-extra-line-list')
# Deletion should fail without the correct role
self.delete(url, {'items': items}, expected_code=403)
self.assignRole('purchase_order.delete')
self.delete(url, {'items': items}, expected_code=200)
self.assertEqual(models.PurchaseOrderExtraLine.objects.count(), n - 2)
def test_po_line_merge_pricing(self):
"""Test that we can create a new PurchaseOrderLineItem via the API."""
self.assignRole('purchase_order.add')
@@ -2245,6 +2275,49 @@ class SalesOrderLineItemTest(OrderTest):
self.filter({'allocated': 'true'}, 1)
self.filter({'allocated': 'false'}, n - 1)
def test_so_line_bulk_delete(self):
"""Test that we can bulk delete multiple SalesOrderLineItems via the API."""
n = models.SalesOrderLineItem.objects.count()
items = list(models.SalesOrderLineItem.objects.values_list('pk', flat=True)[:2])
# Deletion should fail without the correct role
self.delete(self.url, {'items': items}, expected_code=403)
self.assignRole('sales_order.delete')
self.delete(self.url, {'items': items}, expected_code=200)
# We should have 2 less SalesOrderLineItems after deleting them
self.assertEqual(models.SalesOrderLineItem.objects.count(), n - 2)
def test_so_extra_line_bulk_delete(self):
"""Test that we can bulk delete multiple SalesOrderExtraLine items via the API."""
so = models.SalesOrder.objects.first()
models.SalesOrderExtraLine.objects.bulk_create([
models.SalesOrderExtraLine(
order=so, quantity=idx + 1, reference=f'Extra line {idx}'
)
for idx in range(3)
])
n = models.SalesOrderExtraLine.objects.count()
items = list(
models.SalesOrderExtraLine.objects.values_list('pk', flat=True)[:2]
)
url = reverse('api-so-extra-line-list')
# Deletion should fail without the correct role
self.delete(url, {'items': items}, expected_code=403)
self.assignRole('sales_order.delete')
self.delete(url, {'items': items}, expected_code=200)
self.assertEqual(models.SalesOrderExtraLine.objects.count(), n - 2)
def test_so_line_allocated_filters(self):
"""Test filtering by allocation status for a SalesOrderLineItem."""
self.assignRole('sales_order.add')
@@ -3458,6 +3531,53 @@ class ReturnOrderLineItemTests(InvenTreeAPITestCase):
line = models.ReturnOrderLineItem.objects.get(pk=1)
self.assertEqual(float(line.price.amount), 15.75)
def test_bulk_delete(self):
"""Test that we can bulk delete multiple ReturnOrderLineItems via the API."""
n = models.ReturnOrderLineItem.objects.count()
self.assertGreater(n, 0)
items = list(
models.ReturnOrderLineItem.objects.values_list('pk', flat=True)[:1]
)
url = reverse('api-return-order-line-list')
# Deletion should fail without the correct role
self.delete(url, {'items': items}, expected_code=403)
self.assignRole('return_order.delete')
self.delete(url, {'items': items}, expected_code=200)
self.assertEqual(models.ReturnOrderLineItem.objects.count(), n - 1)
def test_extra_line_bulk_delete(self):
"""Test that we can bulk delete multiple ReturnOrderExtraLine items via the API."""
ro = models.ReturnOrder.objects.first()
models.ReturnOrderExtraLine.objects.bulk_create([
models.ReturnOrderExtraLine(
order=ro, quantity=idx + 1, reference=f'Extra line {idx}'
)
for idx in range(3)
])
n = models.ReturnOrderExtraLine.objects.count()
items = list(
models.ReturnOrderExtraLine.objects.values_list('pk', flat=True)[:2]
)
url = reverse('api-return-order-extra-line-list')
# Deletion should fail without the correct role
self.delete(url, {'items': items}, expected_code=403)
self.assignRole('return_order.delete')
self.delete(url, {'items': items}, expected_code=200)
self.assertEqual(models.ReturnOrderExtraLine.objects.count(), n - 2)
class TransferOrderTest(OrderTest):
"""Tests for the TransferOrder API."""
@@ -4098,6 +4218,63 @@ class TransferOrderLineItemTest(OrderTest):
self.filter({'allocated': 'true'}, 2)
self.filter({'allocated': 'false'}, n - 2)
def test_transfer_order_line_bulk_delete(self):
"""Test that we can bulk delete multiple TransferOrderLineItems via the API."""
n = models.TransferOrderLineItem.objects.count()
# Select lines from orders which are not completed (and thus not locked)
items = list(
models.TransferOrderLineItem.objects.exclude(
order__status__in=TransferOrderStatusGroups.COMPLETE
).values_list('pk', flat=True)[:2]
)
# Deletion should fail without the correct role
self.delete(self.url, {'items': items}, expected_code=403)
self.assignRole('transfer_order.delete')
self.delete(self.url, {'items': items}, expected_code=200)
# We should have 2 less TransferOrderLineItems after deleting them
self.assertEqual(models.TransferOrderLineItem.objects.count(), n - 2)
def test_completed_order_locked(self):
"""Test that line items cannot be deleted from a completed TransferOrder."""
self.assignRole('transfer_order.delete')
set_global_setting(models.TransferOrder.UNLOCK_SETTING, False)
order = models.TransferOrder.objects.filter(
status=TransferOrderStatus.PENDING.value, lines__isnull=False
).first()
assert order
# Mark the order as complete
order.status = TransferOrderStatus.COMPLETE.value
order.save()
n = order.lines.count()
self.assertGreater(n, 1)
line = order.lines.first()
detail_url = reverse('api-transfer-order-line-detail', kwargs={'pk': line.pk})
# Single deletion of a line item should fail
self.delete(detail_url, expected_code=400)
# Bulk deletion should also fail (and roll back atomically)
items = list(order.lines.values_list('pk', flat=True))
self.delete(self.url, {'items': items}, expected_code=400)
self.assertEqual(order.lines.count(), n)
# Unlocking completed orders should allow deletion again
set_global_setting(models.TransferOrder.UNLOCK_SETTING, True)
self.delete(detail_url, expected_code=204)
self.assertEqual(order.lines.count(), n - 1)
def test_transfer_order_line_allocated_filters(self):
"""Test filtering by allocation status for a TransferOrderLineItem."""
self.assignRole('transfer_order.add')
@@ -390,7 +390,8 @@ export default function SystemSettings() {
keys={[
'TRANSFERORDER_ENABLED',
'TRANSFERORDER_REFERENCE_PATTERN',
'TRANSFERORDER_REQUIRE_RESPONSIBLE'
'TRANSFERORDER_REQUIRE_RESPONSIBLE',
'TRANSFERORDER_EDIT_COMPLETED_ORDERS'
]}
/>
)
@@ -69,13 +69,11 @@ export default function TransferOrderDetail() {
const orderOpen: boolean =
order.status != toStatus.COMPLETE && order.status != toStatus.CANCELLED;
return orderOpen;
// TODO: does this setting make any sense for Transfer Orders???
// if (orderOpen) {
// return true;
// } else {
// return globalSettings.isSet('TRANSFERORDER_EDIT_COMPLETED_ORDERS');
// }
if (orderOpen) {
return true;
} else {
return globalSettings.isSet('TRANSFERORDER_EDIT_COMPLETED_ORDERS');
}
}, [globalSettings, order.status, toStatus]);
// for now, only permit editing allocations when line items can be edited
@@ -179,6 +179,9 @@ export default function ExtraLineItemTable({
params: {
order: orderId
},
enableSelection: true,
enableBulkDelete: editable && user.hasDeleteRole(role),
afterBulkDelete: orderDetailRefresh,
defaultSortColumn: 'line',
rowActions: rowActions,
tableActions: tableActions
@@ -435,6 +435,9 @@ export function PurchaseOrderLineItemTable({
props={{
enableSelection: true,
enableDownload: true,
enableBulkDelete:
editable && user.hasDeleteRole(UserRoles.purchase_order),
afterBulkDelete: orderDetailRefresh,
defaultSortColumn: 'line',
params: {
...params,
@@ -274,6 +274,9 @@ export default function ReturnOrderLineItemTable({
defaultSortColumn: 'line',
enableSelection:
inProgress && user.hasChangeRole(UserRoles.return_order),
enableBulkDelete:
editable && user.hasDeleteRole(UserRoles.return_order),
afterBulkDelete: orderDetailRefresh,
tableActions: tableActions,
tableFilters: tableFilters,
rowActions: rowActions,
@@ -609,6 +609,9 @@ export default function SalesOrderLineItemTable({
props={{
enableSelection: true,
enableDownload: true,
enableBulkDelete:
editable && user.hasDeleteRole(UserRoles.sales_order),
afterBulkDelete: orderDetailRefresh,
params: {
order: orderId,
part_detail: true
@@ -517,6 +517,9 @@ export default function TransferOrderLineItemTable({
props={{
enableSelection: true,
enableDownload: true,
enableBulkDelete:
editable && user.hasDeleteRole(UserRoles.transfer_order),
afterBulkDelete: orderDetailRefresh,
params: {
order: orderId,
part_detail: true
+1 -1
View File
@@ -283,7 +283,7 @@ test('Build Order - Build Outputs', async ({ browser }) => {
await page.waitForLoadState('networkidle');
// Check the "printing" actions for the selected outputs
await page.getByRole('checkbox', { name: 'Select all records' }).check();
await page.getByRole('checkbox', { name: 'Select all records' }).click();
await page
.getByRole('tabpanel', { name: 'Incomplete Outputs' })
.getByLabel('action-menu-printing-actions')
@@ -507,7 +507,12 @@ test('Purchase Orders - Receive Items', async ({ browser }) => {
await loadTab(page, 'Line Items');
await page.getByRole('cell', { name: '002.02-PCB' }).waitFor();
await page.getByLabel('Select all records').click();
// Select all line items to receive
await page
.getByRole('region', { name: 'Line Items', exact: true })
.getByLabel('Select all records')
.click();
await page.waitForTimeout(100);
await page.getByLabel('action-button-receive-items').click();
@@ -619,7 +624,10 @@ test('Purchase Orders - Receive Virtual Items', async ({ browser }) => {
await loadTab(page, 'Line Items');
await page.getByRole('cell', { name: 'Thumbnail CRM license' }).waitFor();
await page.getByRole('checkbox', { name: 'Select all records' }).click();
await page
.getByRole('region', { name: 'Line Items', exact: true })
.getByLabel('Select all records')
.click();
await page
.getByRole('button', { name: 'action-button-receive-items' })
.click();
+4 -1
View File
@@ -14,7 +14,10 @@ test('Return Orders - Receive Items', async ({ browser }) => {
await page.getByRole('cell', { name: 'WID-REV-A' }).first().waitFor();
await page.getByRole('checkbox', { name: 'Select all records' }).click();
await page
.getByRole('region', { name: 'Line Items', exact: true })
.getByLabel('Select all records')
.click();
await page.getByRole('button', { name: 'action-button-receive-' }).click();
await page.getByRole('banner').getByText('Receive Items').waitFor();
await page.getByRole('button', { name: 'Cancel' }).click();