mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
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:
parent
12cbfcbd95
commit
974ea1ead3
@ -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()
|
||||
|
@ -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,
|
||||
]
|
||||
|
83
InvenTree/generic/states/test_transition.py
Normal file
83
InvenTree/generic/states/test_transition.py
Normal 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')
|
81
InvenTree/generic/states/transition.py
Normal file
81
InvenTree/generic/states/transition.py
Normal 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)
|
@ -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:
|
||||
|
@ -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()
|
||||
|
35
InvenTree/plugin/samples/integration/transition.py
Normal file
35
InvenTree/plugin/samples/integration/transition.py
Normal 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
|
Loading…
Reference in New Issue
Block a user