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:
parent
8407f63656
commit
3fd0e9d304
|
|
@ -15,7 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
|
||||
### Added
|
||||
|
||||
|
||||
- [#12391](https://github.com/inventree/InvenTree/pull/12391) adds facility for bulk deleting line items against orders
|
||||
- [#12388](https://github.com/inventree/InvenTree/pull/12388) adds uniqueness requirements options for the Parameter and ParameterTemplate models. This allows users to specify whether a parameter value should be unique for a given model type, or globally unique across all models.
|
||||
- [#12310](https://github.com/inventree/InvenTree/pull/12310) adds the ability to disassemble (or break apart) assembled stock items into their component parts, based on the Bill of Materials (BOM) associated with the stock item. This allows users to easily break down assembled items into their constituent parts, which can be useful for inventory management and tracking purposes.
|
||||
- [#12117](https://github.com/inventree/InvenTree/pull/12117) adds a "preview" drawer to the InvenTree table component, allowing users to preview the details of a selected row without navigating away from the table view. This feature is optional and can be enabled or disabled via the `PREVIEW_DRAWER_ENABLED` system setting.
|
||||
|
|
|
|||
|
|
@ -148,3 +148,4 @@ The following [global settings](../settings/global.md) are available for transfe
|
|||
{{ globalsetting("TRANSFERORDER_ENABLED") }}
|
||||
{{ globalsetting("TRANSFERORDER_REFERENCE_PATTERN") }}
|
||||
{{ globalsetting("TRANSFERORDER_REQUIRE_RESPONSIBLE") }}
|
||||
{{ globalsetting("TRANSFERORDER_EDIT_COMPLETED_ORDERS") }}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in New Issue