From 301347f1d0b4adec4f2fb26d6a3c4129be015286 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Thu, 13 Feb 2025 23:30:19 +0100 Subject: [PATCH] [UI] Settings to control editing of "completed" orders (#9070) * [UI] Settings to contrl editing of "completed" orders Fixes #8976 * only check for completness if locking is enabled * also lock lines * allow editing of status to get an order "free" again --- src/backend/InvenTree/order/models.py | 53 ++++++++++++++++++++- src/backend/InvenTree/order/tests.py | 66 +++++++++++++++++++++++++++ 2 files changed, 118 insertions(+), 1 deletion(-) diff --git a/src/backend/InvenTree/order/models.py b/src/backend/InvenTree/order/models.py index 29c5383a09..bce0718b11 100644 --- a/src/backend/InvenTree/order/models.py +++ b/src/backend/InvenTree/order/models.py @@ -210,6 +210,7 @@ class Order( """ REQUIRE_RESPONSIBLE_SETTING = None + LOCK_SETTING = None class Meta: """Metaclass options. Abstract ensures no database table is created.""" @@ -219,14 +220,51 @@ class Order( def save(self, *args, **kwargs): """Custom save method for the order models. - Ensures that the reference field is rebuilt whenever the instance is saved. + Enforces various business logics: + - Ensures the object is not locked + - Ensures that the reference field is rebuilt whenever the instance is saved. """ + # check if we are updating the model, not adding it + update = self.pk is not None + + # Locking + if update and self.check_locked(True): + # Ensure that order status can be changed still + if self.get_db_instance().status != self.status: + pass + else: + raise ValidationError({ + 'reference': _('This order is locked and cannot be modified') + }) + + # Reference calculations self.reference_int = self.rebuild_reference_field(self.reference) if not self.creation_date: self.creation_date = InvenTree.helpers.current_date() super().save(*args, **kwargs) + def check_locked(self, db: bool = False) -> bool: + """Check if this order is 'locked'. + + Args: + db: If True, check with the database. If False, check the instance (default False). + """ + return ( + self.LOCK_SETTING + and get_global_setting(self.LOCK_SETTING) + and self.check_complete(db) + ) + + def check_complete(self, db: bool = False) -> bool: + """Check if this order is 'complete'. + + Args: + db: If True, check with the database. If False, check the instance (default False). + """ + status = self.get_db_instance().status if db else self.status + return status in self.get_status_class().COMPLETE + def clean(self): """Custom clean method for the generic order class.""" super().clean() @@ -397,6 +435,7 @@ class PurchaseOrder(TotalPriceMixin, Order): REFERENCE_PATTERN_SETTING = 'PURCHASEORDER_REFERENCE_PATTERN' REQUIRE_RESPONSIBLE_SETTING = 'PURCHASEORDER_REQUIRE_RESPONSIBLE' STATUS_CLASS = PurchaseOrderStatus + LOCK_SETTING = 'PURCHASEORDER_EDIT_COMPLETED_ORDERS' class Meta: """Model meta options.""" @@ -970,6 +1009,7 @@ class SalesOrder(TotalPriceMixin, Order): REFERENCE_PATTERN_SETTING = 'SALESORDER_REFERENCE_PATTERN' REQUIRE_RESPONSIBLE_SETTING = 'SALESORDER_REQUIRE_RESPONSIBLE' STATUS_CLASS = SalesOrderStatus + LOCK_SETTING = 'SALESORDER_EDIT_COMPLETED_ORDERS' class Meta: """Model meta options.""" @@ -1449,6 +1489,11 @@ class OrderLineItem(InvenTree.models.InvenTreeMetadataModel): Calls save method on the linked order """ + if self.order and self.order.check_locked(): + raise ValidationError({ + 'reference': _('The order is locked and cannot be modified') + }) + super().save(*args, **kwargs) self.order.save() @@ -1457,6 +1502,11 @@ class OrderLineItem(InvenTree.models.InvenTreeMetadataModel): Calls save method on the linked order """ + if self.order and self.order.check_locked(): + raise ValidationError({ + 'reference': _('The order is locked and cannot be modified') + }) + super().delete(*args, **kwargs) self.order.save() @@ -2215,6 +2265,7 @@ class ReturnOrder(TotalPriceMixin, Order): REFERENCE_PATTERN_SETTING = 'RETURNORDER_REFERENCE_PATTERN' REQUIRE_RESPONSIBLE_SETTING = 'RETURNORDER_REQUIRE_RESPONSIBLE' STATUS_CLASS = ReturnOrderStatus + LOCK_SETTING = 'RETURNORDER_EDIT_COMPLETED_ORDERS' class Meta: """Model meta options.""" diff --git a/src/backend/InvenTree/order/tests.py b/src/backend/InvenTree/order/tests.py index 9c2b4571df..cab31b1e65 100644 --- a/src/backend/InvenTree/order/tests.py +++ b/src/backend/InvenTree/order/tests.py @@ -61,6 +61,72 @@ class OrderTest(TestCase, ExchangeRateMixin): order.save() self.assertEqual(order.reference_int, 12345) + def test_locking(self): + """Test the (auto)locking functionality of the (Purchase)Order model.""" + order = PurchaseOrder.objects.get(pk=1) + + order.status = PurchaseOrderStatus.PENDING + order.save() + self.assertFalse(order.check_locked()) + + order.status = PurchaseOrderStatus.COMPLETE + order.save() + self.assertFalse(order.check_locked()) + + order.add_line_item(SupplierPart.objects.get(pk=100), 100) + last_line = order.lines.last() + self.assertEqual(last_line.quantity, 100) + + # Reset + order.status = PurchaseOrderStatus.PENDING + order.save() + + # Turn on auto-locking + set_global_setting(PurchaseOrder.LOCK_SETTING, True) + # still not locked + self.assertFalse(order.check_locked()) + + order.status = PurchaseOrderStatus.COMPLETE + # the instance is locked, the db instance is not + self.assertFalse(order.check_locked(True)) + self.assertTrue(order.check_locked()) + order.save() + # now everything is locked + self.assertTrue(order.check_locked(True)) + self.assertTrue(order.check_locked()) + + # No editing allowed + with self.assertRaises(django_exceptions.ValidationError): + order.description = 'test1' + order.save() + order.refresh_from_db() + self.assertEqual(order.description, 'Ordering some screws') + + # Also no adding of line items + with self.assertRaises(django_exceptions.ValidationError): + order.add_line_item(SupplierPart.objects.get(pk=100), 100) + # or deleting + with self.assertRaises(django_exceptions.ValidationError): + last_line.delete() + + # Still can create a completed item + PurchaseOrder.objects.create( + supplier=Company.objects.get(pk=1), + reference='PO-99999', + status=PurchaseOrderStatus.COMPLETE, + ) + + # No editing (except status ;-) ) allowed + order.status = PurchaseOrderStatus.PENDING + order.save() + + # Now it is a free for all again + order.description = 'test2' + order.save() + + order.refresh_from_db() + self.assertEqual(order.description, 'test2') + def test_overdue(self): """Test overdue status functionality.""" today = datetime.now().date()