diff --git a/InvenTree/InvenTree/apps.py b/InvenTree/InvenTree/apps.py index 609602796e..30f787eb01 100644 --- a/InvenTree/InvenTree/apps.py +++ b/InvenTree/InvenTree/apps.py @@ -33,6 +33,7 @@ class InvenTreeConfig(AppConfig): - Starting regular tasks - Updating exchange rates - Collecting notification methods + - Collecting state transition methods - Adding users set in the current environment """ # skip loading if plugin registry is not loaded or we run in a background thread @@ -52,6 +53,7 @@ class InvenTreeConfig(AppConfig): InvenTree.tasks.offload_task(InvenTree.tasks.check_for_migrations) self.collect_notification_methods() + self.collect_state_transition_methods() # Ensure the unit registry is loaded InvenTree.conversion.get_unit_registry() @@ -251,3 +253,9 @@ class InvenTreeConfig(AppConfig): from common.notifications import storage storage.collect() + + def collect_state_transition_methods(self): + """Collect all state transition methods.""" + from generic.states import storage + + storage.collect() diff --git a/InvenTree/generic/states/__init__.py b/InvenTree/generic/states/__init__.py index 952c31557a..772cec0ec0 100644 --- a/InvenTree/generic/states/__init__.py +++ b/InvenTree/generic/states/__init__.py @@ -7,7 +7,11 @@ States can be extended with custom options for each InvenTree instance - those o """ from .states import StatusCode +from .transition import StateTransitionMixin, TransitionMethod, storage __all__ = [ StatusCode, + storage, + TransitionMethod, + StateTransitionMixin, ] diff --git a/InvenTree/generic/states/test_transition.py b/InvenTree/generic/states/test_transition.py new file mode 100644 index 0000000000..28c112c97f --- /dev/null +++ b/InvenTree/generic/states/test_transition.py @@ -0,0 +1,83 @@ +"""Tests for state transition mechanism.""" + +from InvenTree.unit_test import InvenTreeTestCase + +from .transition import StateTransitionMixin, TransitionMethod, storage + + +class MyPrivateError(NotImplementedError): + """Error for testing purposes.""" + + +def dflt(*args, **kwargs): + """Default function for testing.""" + raise MyPrivateError('dflt') + + +def _clean_storage(refs): + """Clean the storage.""" + for ref in refs: + del ref + storage.collect() + + +class TransitionTests(InvenTreeTestCase): + """Tests for basic NotificationMethod.""" + + def test_class(self): + """Ensure that the class itself works.""" + + class ErrorImplementation(TransitionMethod): + ... + + with self.assertRaises(NotImplementedError): + ErrorImplementation() + + _clean_storage([ErrorImplementation]) + + def test_storage(self): + """Ensure that the storage collection mechanism works.""" + + class RaisingImplementation(TransitionMethod): + def transition(self, *args, **kwargs): + raise MyPrivateError('RaisingImplementation') + + # Ensure registering works + storage.collect() + + # Ensure the class is registered + self.assertIn(RaisingImplementation, storage.list) + + # Ensure stuff is passed to the class + with self.assertRaises(MyPrivateError) as exp: + StateTransitionMixin.handle_transition(0, 1, self, self, dflt) + self.assertEqual(str(exp.exception), 'RaisingImplementation') + + _clean_storage([RaisingImplementation]) + + def test_function(self): + """Ensure that a TransitionMethod's function is called.""" + + # Setup + class ValidImplementationNoEffect(TransitionMethod): + def transition(self, *args, **kwargs): + return False # Return false to test that that work too + + class ValidImplementation(TransitionMethod): + def transition(self, *args, **kwargs): + return 1234 + + storage.collect() + self.assertIn(ValidImplementationNoEffect, storage.list) + self.assertIn(ValidImplementation, storage.list) + + # Ensure that the function is called + self.assertEqual(StateTransitionMixin.handle_transition(0, 1, self, self, dflt), 1234) + + _clean_storage([ValidImplementationNoEffect, ValidImplementation]) + + def test_default_function(self): + """Ensure that the default function is called.""" + with self.assertRaises(MyPrivateError) as exp: + StateTransitionMixin.handle_transition(0, 1, self, self, dflt) + self.assertEqual(str(exp.exception), 'dflt') diff --git a/InvenTree/generic/states/transition.py b/InvenTree/generic/states/transition.py new file mode 100644 index 0000000000..02911ac658 --- /dev/null +++ b/InvenTree/generic/states/transition.py @@ -0,0 +1,81 @@ +"""Classes and functions for plugin controlled object state transitions.""" +import InvenTree.helpers + + +class TransitionMethod: + """Base class for all transition classes. + + Must implement a method called `transition` that takes both args and kwargs. + """ + + def __init__(self) -> None: + """Check that the method is defined correctly. + + This checks that: + - The needed functions are implemented + """ + # Check if a sending fnc is defined + if (not hasattr(self, 'transition')): + raise NotImplementedError('A TransitionMethod must define a `transition` method') + + +class TransitionMethodStorageClass: + """Class that works as registry for all available transition methods in InvenTree. + + Is initialized on startup as one instance named `storage` in this file. + """ + + list = None + + def collect(self): + """Collect all classes in the environment that are transition methods.""" + filtered_list = {} + for item in InvenTree.helpers.inheritors(TransitionMethod): + # Try if valid + try: + item() + except NotImplementedError: + continue + filtered_list[f'{item.__module__}.{item.__qualname__}'] = item + + self.list = list(filtered_list.values()) + + # Ensure the list has items + if not self.list: + self.list = [] + + +storage = TransitionMethodStorageClass() + + +class StateTransitionMixin: + """Mixin class to enable state transitions. + + This mixin is used to add state transitions handling to a model. With this you can apply custom logic to state transitions via plugins. + ```python + class MyModel(StateTransitionMixin, models.Model): + def some_dummy_function(self, *args, **kwargs): + pass + + def action(self, *args, **kwargs): + self.handle_transition(0, 1, self, self.some_dummy_function) + ``` + """ + + def handle_transition(self, current_state, target_state, instance, default_action, **kwargs): + """Handle a state transition for an object. + + Args: + current_state: Current state of instance + target_state: Target state of instance + instance: Object instance + default_action: Default action to be taken if none of the transitions returns a boolean true value + """ + # Check if there is a custom override function for this transition + for override in storage.list: + rslt = override.transition(current_state, target_state, instance, default_action, **kwargs) + if rslt: + return rslt + + # Default action + return default_action(current_state, target_state, instance, **kwargs) diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py index 7da338a4c1..1268faf5ac 100644 --- a/InvenTree/order/models.py +++ b/InvenTree/order/models.py @@ -33,6 +33,7 @@ import users.models as UserModels from common.notifications import InvenTreeNotificationBodies from common.settings import currency_code_default from company.models import Address, Company, Contact, SupplierPart +from generic.states import StateTransitionMixin from InvenTree.exceptions import log_error from InvenTree.fields import (InvenTreeModelMoneyField, InvenTreeURLField, RoundingDecimalField) @@ -160,7 +161,7 @@ class TotalPriceMixin(models.Model): return total -class Order(InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, ReferenceIndexingMixin): +class Order(StateTransitionMixin, InvenTreeBarcodeMixin, InvenTreeNotesMixin, MetadataMixin, ReferenceIndexingMixin): """Abstract model for an order. Instances of this class: @@ -479,13 +480,13 @@ class PurchaseOrder(TotalPriceMixin, Order): return line - @transaction.atomic - def place_order(self): + # region state changes + def _action_place(self, *args, **kwargs): """Marks the PurchaseOrder as PLACED. Order must be currently PENDING. """ - if self.status == PurchaseOrderStatus.PENDING: + if self.is_pending: self.status = PurchaseOrderStatus.PLACED.value self.issue_date = datetime.now().date() self.save() @@ -500,8 +501,7 @@ class PurchaseOrder(TotalPriceMixin, Order): content=InvenTreeNotificationBodies.NewOrder ) - @transaction.atomic - def complete_order(self): + def _action_complete(self, *args, **kwargs): """Marks the PurchaseOrder as COMPLETE. Order must be currently PLACED. @@ -519,6 +519,21 @@ class PurchaseOrder(TotalPriceMixin, Order): trigger_event('purchaseorder.completed', id=self.pk) + @transaction.atomic + def place_order(self): + """Attempt to transition to PLACED status.""" + return self.handle_transition(self.status, PurchaseOrderStatus.PLACED.value, self, self._action_place) + + @transaction.atomic + def complete_order(self): + """Attempt to transition to COMPLETE status.""" + return self.handle_transition(self.status, PurchaseOrderStatus.COMPLETE.value, self, self._action_complete) + + @transaction.atomic + def cancel_order(self): + """Attempt to transition to CANCELLED status.""" + return self.handle_transition(self.status, PurchaseOrderStatus.CANCELLED.value, self, self._action_cancel) + @property def is_pending(self): """Return True if the PurchaseOrder is 'pending'""" @@ -529,6 +544,7 @@ class PurchaseOrder(TotalPriceMixin, Order): """Return True if the PurchaseOrder is 'open'""" return self.status in PurchaseOrderStatusGroups.OPEN + @property def can_cancel(self): """A PurchaseOrder can only be cancelled under the following circumstances. @@ -540,10 +556,9 @@ class PurchaseOrder(TotalPriceMixin, Order): PurchaseOrderStatus.PENDING.value ] - @transaction.atomic - def cancel_order(self): + def _action_cancel(self, *args, **kwargs): """Marks the PurchaseOrder as CANCELLED.""" - if self.can_cancel(): + if self.can_cancel: self.status = PurchaseOrderStatus.CANCELLED.value self.save() @@ -556,6 +571,7 @@ class PurchaseOrder(TotalPriceMixin, Order): exclude=self.created_by, content=InvenTreeNotificationBodies.OrderCanceled ) + # endregion def pending_line_items(self): """Return a list of pending line items for this order. @@ -886,12 +902,12 @@ class SalesOrder(TotalPriceMixin, Order): return True + # region state changes def place_order(self): """Deprecated version of 'issue_order'""" self.issue_order() - @transaction.atomic - def issue_order(self): + def _action_place(self, *args, **kwargs): """Change this order from 'PENDING' to 'IN_PROGRESS'""" if self.status == SalesOrderStatus.PENDING: self.status = SalesOrderStatus.IN_PROGRESS.value @@ -900,8 +916,10 @@ class SalesOrder(TotalPriceMixin, Order): trigger_event('salesorder.issued', id=self.pk) - def complete_order(self, user, **kwargs): + def _action_complete(self, *args, **kwargs): """Mark this order as "complete.""" + user = kwargs.pop('user', None) + if not self.can_complete(**kwargs): return False @@ -919,19 +937,19 @@ class SalesOrder(TotalPriceMixin, Order): return True + @property def can_cancel(self): """Return True if this order can be cancelled.""" return self.is_open - @transaction.atomic - def cancel_order(self): + def _action_cancel(self, *args, **kwargs): """Cancel this order (only if it is "open"). Executes: - Mark the order as 'cancelled' - Delete any StockItems which have been allocated """ - if not self.can_cancel(): + if not self.can_cancel: return False self.status = SalesOrderStatus.CANCELLED.value @@ -953,6 +971,22 @@ class SalesOrder(TotalPriceMixin, Order): return True + @transaction.atomic + def issue_order(self): + """Attempt to transition to IN_PROGRESS status.""" + return self.handle_transition(self.status, SalesOrderStatus.IN_PROGRESS.value, self, self._action_place) + + @transaction.atomic + def complete_order(self, user, **kwargs): + """Attempt to transition to SHIPPED status.""" + return self.handle_transition(self.status, SalesOrderStatus.SHIPPED.value, self, self._action_complete, user=user, **kwargs) + + @transaction.atomic + def cancel_order(self): + """Attempt to transition to CANCELLED status.""" + return self.handle_transition(self.status, SalesOrderStatus.CANCELLED.value, self, self._action_cancel) + # endregion + @property def line_count(self): """Return the total number of lines associated with this order""" @@ -1782,6 +1816,7 @@ class ReturnOrder(TotalPriceMixin, Order): help_text=_('Date order was completed') ) + # region state changes @property def is_pending(self): """Return True if this order is pending""" @@ -1797,8 +1832,7 @@ class ReturnOrder(TotalPriceMixin, Order): """Return True if this order is fully received""" return not self.lines.filter(received_date=None).exists() - @transaction.atomic - def cancel_order(self): + def _action_cancel(self, *args, **kwargs): """Cancel this ReturnOrder (if not already cancelled)""" if self.status != ReturnOrderStatus.CANCELLED: self.status = ReturnOrderStatus.CANCELLED.value @@ -1814,8 +1848,7 @@ class ReturnOrder(TotalPriceMixin, Order): content=InvenTreeNotificationBodies.OrderCanceled ) - @transaction.atomic - def complete_order(self): + def _action_complete(self, *args, **kwargs): """Complete this ReturnOrder (if not already completed)""" if self.status == ReturnOrderStatus.IN_PROGRESS: self.status = ReturnOrderStatus.COMPLETE.value @@ -1828,8 +1861,7 @@ class ReturnOrder(TotalPriceMixin, Order): """Deprecated version of 'issue_order""" self.issue_order() - @transaction.atomic - def issue_order(self): + def _action_place(self, *args, **kwargs): """Issue this ReturnOrder (if currently pending)""" if self.status == ReturnOrderStatus.PENDING: self.status = ReturnOrderStatus.IN_PROGRESS.value @@ -1838,6 +1870,22 @@ class ReturnOrder(TotalPriceMixin, Order): trigger_event('returnorder.issued', id=self.pk) + @transaction.atomic + def issue_order(self): + """Attempt to transition to IN_PROGRESS status.""" + return self.handle_transition(self.status, ReturnOrderStatus.IN_PROGRESS.value, self, self._action_place) + + @transaction.atomic + def complete_order(self): + """Attempt to transition to COMPLETE status.""" + return self.handle_transition(self.status, ReturnOrderStatus.COMPLETE.value, self, self._action_complete) + + @transaction.atomic + def cancel_order(self): + """Attempt to transition to CANCELLED status.""" + return self.handle_transition(self.status, ReturnOrderStatus.CANCELLED.value, self, self._action_cancel) + # endregion + @transaction.atomic def receive_line_item(self, line, location, user, note=''): """Receive a line item against this ReturnOrder: diff --git a/InvenTree/order/serializers.py b/InvenTree/order/serializers.py index 939123b660..b512642537 100644 --- a/InvenTree/order/serializers.py +++ b/InvenTree/order/serializers.py @@ -254,7 +254,7 @@ class PurchaseOrderCancelSerializer(serializers.Serializer): """Save the serializer to 'cancel' the order""" order = self.context['order'] - if not order.can_cancel(): + if not order.can_cancel: raise ValidationError(_("Order cannot be cancelled")) order.cancel_order() diff --git a/InvenTree/plugin/samples/integration/transition.py b/InvenTree/plugin/samples/integration/transition.py new file mode 100644 index 0000000000..4de3b140a9 --- /dev/null +++ b/InvenTree/plugin/samples/integration/transition.py @@ -0,0 +1,35 @@ +"""Sample implementation of state transition implementation.""" + +from common.notifications import trigger_notification +from generic.states import TransitionMethod +from InvenTree.status_codes import ReturnOrderStatus +from order.models import ReturnOrder +from plugin import InvenTreePlugin + + +class SampleTransitionPlugin(InvenTreePlugin): + """A sample plugin which shows how state transitions might be implemented.""" + + NAME = "SampleTransitionPlugin" + + class ReturnChangeHandler(TransitionMethod): + """Transition method for PurchaseOrder objects.""" + + def transition(current_state, target_state, instance, default_action, **kwargs): # noqa: N805 + """Example override function for state transition.""" + # Only act on ReturnOrders that should be completed + if not isinstance(instance, ReturnOrder) or not (target_state == ReturnOrderStatus.COMPLETE.value): + return False + + # Only allow proceeding if the return order has a responsible user assigned + if not instance.responsible: + # Trigger whoever created the return order + instance.created_by + trigger_notification( + instance, + 'sampel_123_456', + targets=[instance.created_by, ], + context={'message': "Return order without responsible owner can not be completed!"}, + ) + return True # True means nothing will happen + return False # Do not act