mirror of
https://github.com/inventree/InvenTree.git
synced 2026-08-22 13:05:16 +00:00
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:
@@ -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': _(
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user