State transition support for generic states (#6017)

* Added state transition support to generic states

* make can_cancel a property everywhere

* add check if method is defined

* add unit tests

* extend tests

* fixed loading of broken classes

* added test to ensure transition functions are called

* added cleaning step for custom classes

* change description texts

* added state transitions to SalesOrder, ReturnOrder

* renamed internal functions

* reduced diff

* fix keyword def

* added return funcion

* fixed test assertation

* replace counting with direct asserting

* also pass kwargs

* added sample for transition plugin
This commit is contained in:
Matthias Mair 2023-12-07 04:48:09 +01:00 committed by GitHub
parent 12cbfcbd95
commit 974ea1ead3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 281 additions and 22 deletions

View File

@ -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()

View File

@ -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,
]

View File

@ -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')

View File

@ -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)

View File

@ -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:

View File

@ -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()

View File

@ -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