2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-08-07 12:22:11 +00:00

Transition plugin (#10088)

* Add new enum

* Define StateTransitionMixin class

* Import

* Rename plugin mixin class

- Avoid naming clash

* Offload transitions to plugin registry

* fix imports

* Fix default value

* Fix sample transition class

* Refactor unit test

* Add docs

* Fix type hinting

* Check for expected message

* Tests for various failure modes

* Tweak query count limit

* Exclude lines from coverage

* Remove debug code

---------

Co-authored-by: Matthias Mair <code@mjmair.com>
This commit is contained in:
Oliver
2025-08-01 09:35:03 +10:00
committed by GitHub
parent 4794c6d860
commit a79ab40f5e
11 changed files with 338 additions and 142 deletions

View File

@@ -0,0 +1,16 @@
---
title: Transition Mixin
---
## TransitionMixin
The `TransitionMixin` allows plugins to provide custom state transition logic for InvenTree models.
Model types which support transition between different "states" (e.g. orders), can be extended to support custom transition logic by implementing the `TransitionMixin` class.
This allows for custom functionality to be executed when a state transition occurs, such as sending notifications, updating related models, or performing other actions.
Additionally, the mixin can be used to prevent certain transitions from occurring, or to modify the transition logic based on custom conditions.
!!! info "More Info"
For more information on this plugin mixin, refer to the InvenTree source code. [A working example is available as a starting point]({{ sourcefile("/src/backend/InvenTree/plugin/samples/integration/transition.py") }}).

View File

@@ -233,6 +233,7 @@ nav:
- Report Mixin: plugins/mixins/report.md - Report Mixin: plugins/mixins/report.md
- Schedule Mixin: plugins/mixins/schedule.md - Schedule Mixin: plugins/mixins/schedule.md
- Settings Mixin: plugins/mixins/settings.md - Settings Mixin: plugins/mixins/settings.md
- Transition Mixin: plugins/mixins/transition.md
- URL Mixin: plugins/mixins/urls.md - URL Mixin: plugins/mixins/urls.md
- User Interface Mixin: plugins/mixins/ui.md - User Interface Mixin: plugins/mixins/ui.md
- Validation Mixin: plugins/mixins/validation.md - Validation Mixin: plugins/mixins/validation.md

View File

@@ -70,7 +70,6 @@ class InvenTreeConfig(AppConfig):
InvenTree.tasks.offload_task(InvenTree.tasks.check_for_migrations) InvenTree.tasks.offload_task(InvenTree.tasks.check_for_migrations)
self.update_site_url() self.update_site_url()
self.collect_state_transition_methods()
# Ensure the unit registry is loaded # Ensure the unit registry is loaded
InvenTree.conversion.get_unit_registry() InvenTree.conversion.get_unit_registry()
@@ -312,12 +311,6 @@ class InvenTreeConfig(AppConfig):
# do not try again # do not try again
settings.USER_ADDED_FILE = True settings.USER_ADDED_FILE = True
def collect_state_transition_methods(self):
"""Collect all state transition methods."""
from generic.states import storage
storage.collect()
def ensure_migrations_done(self=None): def ensure_migrations_done(self=None):
"""Ensures there are no open migrations, stop if inconsistent state.""" """Ensures there are no open migrations, stop if inconsistent state."""
global MIGRATIONS_CHECK_DONE global MIGRATIONS_CHECK_DONE

View File

@@ -996,7 +996,7 @@ class BuildOverallocationTest(BuildAPITest):
self.url, self.url,
{'accept_overallocated': 'trim'}, {'accept_overallocated': 'trim'},
expected_code=201, expected_code=201,
max_query_count=375, max_query_count=400,
) )
# Note: Large number of queries is due to pricing recalculation for each stock item # Note: Large number of queries is due to pricing recalculation for each stock item

View File

@@ -7,7 +7,7 @@ States can be extended with custom options for each InvenTree instance - those o
""" """
from .states import ColorEnum, StatusCode, StatusCodeMixin from .states import ColorEnum, StatusCode, StatusCodeMixin
from .transition import StateTransitionMixin, TransitionMethod, storage from .transition import StateTransitionMixin, TransitionMethod
__all__ = [ __all__ = [
'ColorEnum', 'ColorEnum',
@@ -15,5 +15,4 @@ __all__ = [
'StatusCode', 'StatusCode',
'StatusCodeMixin', 'StatusCodeMixin',
'TransitionMethod', 'TransitionMethod',
'storage',
] ]

View File

@@ -1,107 +1,123 @@
"""Tests for state transition mechanism.""" """Tests for state transition mechanism."""
from django.core.exceptions import ValidationError
from InvenTree.unit_test import InvenTreeTestCase from InvenTree.unit_test import InvenTreeTestCase
from order.models import ReturnOrder
from .transition import StateTransitionMixin, TransitionMethod, storage from order.status_codes import ReturnOrderStatus
from plugin import registry
# Global variables to determine which transition classes raises an exception
raise_storage = False
raise_function = False
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): class TransitionTests(InvenTreeTestCase):
"""Tests for basic TransitionMethod.""" """Tests for custom state transition logic."""
def test_class(self): fixtures = ['company', 'return_order', 'part', 'stock', 'location', 'category']
"""Ensure that the class itself works."""
class ErrorImplementation(TransitionMethod): ... def setUp(self):
"""Set up the test environment."""
super().setUp()
self.ensurePluginsLoaded()
with self.assertRaises(NotImplementedError): def test_return_order(self):
ErrorImplementation() """Test transition of a return order."""
# Ensure plugin is enabled
registry.set_plugin_state('sample-transition', True)
_clean_storage([ErrorImplementation]) ro = ReturnOrder.objects.get(pk=2)
self.assertEqual(ro.status, ReturnOrderStatus.IN_PROGRESS.value)
def test_storage(self): # Attempt to transition to COMPLETE state
"""Ensure that the storage collection mechanism works.""" # This should fail - due to the StateTransitionMixin logic
global raise_storage with self.assertRaises(ValidationError) as e:
global raise_function ro.complete_order()
raise_storage = True self.assertIn(
raise_function = False 'Return order without responsible owner can not be completed',
str(e.exception),
class RaisingImplementation(TransitionMethod):
def transition(self, *args, **kwargs):
"""Custom transition method."""
global raise_storage
if raise_storage:
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."""
global raise_storage
global raise_function
raise_storage = False
raise_function = True
# 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):
global raise_function
if raise_function:
return 1234
else:
return False # pragma: no cover # Return false to keep other transitions working
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]) # Now disable the plugin
registry.set_plugin_state('sample-transition', False)
def test_default_function(self): # Attempt to transition again
"""Ensure that the default function is called.""" ro.complete_order()
with self.assertRaises(MyPrivateError) as exp: ro.refresh_from_db()
StateTransitionMixin.handle_transition(0, 1, self, self, dflt)
self.assertEqual(str(exp.exception), 'dflt') self.assertEqual(ro.status, ReturnOrderStatus.COMPLETE.value)
def test_broken_transition_plugin(self):
"""Test handling of an intentionally broken transition plugin.
This test uses a custom plugin which is designed to fail in various ways.
"""
from error_report.models import Error
Error.objects.all().delete()
# Ensure the correct plugin is enabled
registry.set_plugin_state('sample-transition', False)
registry.set_plugin_state('sample-broken-transition', True)
ro = ReturnOrder.objects.get(pk=2)
self.assertEqual(ro.status, ReturnOrderStatus.IN_PROGRESS.value)
# Transition to "ON HOLD" state
ro.hold_order()
# Ensure plugin starts in a known state
plugin = registry.get_plugin('sample-broken-transition')
plugin.set_setting('BROKEN_GET_METHOD', False)
plugin.set_setting('WRONG_RETURN_TYPE', False)
plugin.set_setting('WRONG_RETURN_VALUE', False)
# Expect a "warning" message on each run
# This assures us that the transition handler is being called
msg = 'get_transition_handlers is intentionally broken in this plugin'
with self.assertWarnsMessage(UserWarning, msg):
# No error should occur here
ro.complete_order()
self.assertEqual(ro.status, ReturnOrderStatus.ON_HOLD.value)
# No error should be logged
self.assertEqual(0, Error.objects.count())
# Now, enable the "WRONG_RETURN_VALUE" setting
plugin.set_setting('WRONG_RETURN_VALUE', True)
with self.assertLogs('inventree', level='ERROR') as cm:
with self.assertWarnsMessage(UserWarning, msg):
# No error should occur here
ro.issue_order()
self.assertEqual(ro.status, ReturnOrderStatus.IN_PROGRESS.value)
# Ensure correct eroror was logged
self.assertIn('Invalid transition handler type: 1', str(cm.output[0]))
# Now, enable the "WRONG_RETURN_TYPE" setting
plugin.set_setting('WRONG_RETURN_TYPE', True)
with self.assertLogs('inventree', level='ERROR') as cm:
with self.assertWarnsMessage(UserWarning, msg):
# No error should occur here
ro.hold_order()
self.assertEqual(ro.status, ReturnOrderStatus.ON_HOLD.value)
# Ensure correct error was logged
self.assertIn(
'Plugin sample-broken-transition returned invalid type for transition handlers',
str(cm.output[0]),
)
# Now, enable the "BROKEN_GET_METHOD" setting
plugin.set_setting('BROKEN_GET_METHOD', True)
with self.assertLogs('inventree', level='ERROR') as cm:
with self.assertWarnsMessage(UserWarning, msg):
ro.issue_order()
self.assertEqual(ro.status, ReturnOrderStatus.IN_PROGRESS.value)
# Ensure correct error was logged
self.assertIn(
"ValueError('This is a broken transition plugin!')", str(cm.output[0])
)

View File

@@ -1,6 +1,10 @@
"""Classes and functions for plugin controlled object state transitions.""" """Classes and functions for plugin controlled object state transitions."""
import InvenTree.helpers from django.db.models import Model
import structlog
logger = structlog.get_logger('inventree')
class TransitionMethod: class TransitionMethod:
@@ -16,39 +20,52 @@ class TransitionMethod:
- The needed functions are implemented - The needed functions are implemented
""" """
# Check if a sending fnc is defined # Check if a sending fnc is defined
if not hasattr(self, 'transition'): if not hasattr(self, 'transition'): # pragma: no cover
raise NotImplementedError( raise NotImplementedError(
'A TransitionMethod must define a `transition` method' 'A TransitionMethod must define a `transition` method'
) )
def transition(
self,
current_state: int,
target_state: int,
instance: Model,
default_action: callable,
**kwargs,
) -> bool:
"""Perform a state transition.
class TransitionMethodStorageClass: Success:
"""Class that works as registry for all available transition methods in InvenTree. - The custom transition logic succeeded
- Return True result
- No further transitions are attempted
Ignore:
- The custom transition logic did not apply
- Return False result
- Further transitions are attempted (if available)
- Default action is called if no transition was successful
Failure:
- The custom transition logic failed
- Raise a ValidationError
- No further transitions are attempted
- Default action is not called
Is initialized on startup as one instance named `storage` in this file. Arguments:
""" current_state: int - Current state of the instance.
target_state: int - Target state to transition to.
instance: Model - The object instance to transition.
default_action: callable - Default action to be taken if no transition is successful.
**kwargs: Additional keyword arguments for custom logic.
list = None Returns:
result: bool - True if the transition method was successful, False otherwise.
def collect(self): Raises:
"""Collect all classes in the environment that are transition methods.""" ValidationError: Alert the user that the transition failued
filtered_list = {} """
for item in InvenTree.helpers.inheritors(TransitionMethod): raise NotImplementedError(
# Try if valid 'TransitionMethod.transition must be implemented'
try: ) # pragma: no cover
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: class StateTransitionMixin:
@@ -76,13 +93,37 @@ class StateTransitionMixin:
instance: Object instance instance: Object instance
default_action: Default action to be taken if none of the transitions returns a boolean true value 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 from InvenTree.exceptions import log_error
for override in storage.list: from plugin import PluginMixinEnum, registry
rslt = override.transition(
current_state, target_state, instance, default_action, **kwargs transition_plugins = registry.with_mixin(PluginMixinEnum.STATE_TRANSITION)
)
if rslt: for plugin in transition_plugins:
return rslt try:
handlers = plugin.get_transition_handlers()
except Exception:
log_error('get_transition_handlers', plugin=plugin)
continue
if type(handlers) is not list:
logger.error(
'Plugin %s returned invalid type for transition handlers',
plugin.slug,
)
continue
for handler in handlers:
if not isinstance(handler, TransitionMethod):
logger.error('Invalid transition handler type: %s', handler)
continue
# Call the transition method
result = handler.transition(
current_state, target_state, instance, default_action, **kwargs
)
if result:
return result
# Default action # Default action
return default_action(current_state, target_state, instance, **kwargs) return default_action(current_state, target_state, instance, **kwargs)

View File

@@ -0,0 +1,63 @@
"""Plugin mixin for handling state transitions."""
import structlog
from generic.states.transition import TransitionMethod
from plugin import PluginMixinEnum
logger = structlog.get_logger('inventree')
class TransitionMixin:
"""Mixin class for handling state transitions.
- This mixin allows plugins to define custom state transition methods.
- These methods can be used to apply custom logic when an object transitions from one state to another.
"""
# List of transition methods that can be used by plugins
TRANSITION_HANDLERS: list[TransitionMethod]
class MixinMeta:
"""Meta class for the StateTransitionMixin."""
MIXIN_NAME = 'StateTransition'
def __init__(self):
"""Initialize the mixin and register it."""
super().__init__()
self.add_mixin(
PluginMixinEnum.STATE_TRANSITION, 'has_transition_handlers', __class__
)
@property
def has_transition_handlers(self) -> bool:
"""Check if there are any transition handlers defined."""
try:
result = bool(self.get_transition_handlers())
return result
except Exception:
return False
def get_transition_handlers(self) -> list[TransitionMethod]:
"""Get the list of transition methods available in this mixin.
The default implementation returns the TRANSITION_HANDLERS list.
"""
handlers = getattr(self, 'TRANSITION_HANDLERS', None) or []
if not isinstance(handlers, list):
raise TypeError(
'TRANSITION_HANDLERS must be a list of TransitionMethod instances'
)
handler_methods = []
for handler in handlers:
if not isinstance(handler, TransitionMethod):
logger.error('Invalid transition handler type: %s', handler)
continue
handler_methods.append(handler)
return handler_methods

View File

@@ -13,6 +13,7 @@ from plugin.base.integration.NotificationMixin import NotificationMixin
from plugin.base.integration.ReportMixin import ReportMixin from plugin.base.integration.ReportMixin import ReportMixin
from plugin.base.integration.ScheduleMixin import ScheduleMixin from plugin.base.integration.ScheduleMixin import ScheduleMixin
from plugin.base.integration.SettingsMixin import SettingsMixin from plugin.base.integration.SettingsMixin import SettingsMixin
from plugin.base.integration.TransitionMixin import TransitionMixin
from plugin.base.integration.UrlsMixin import UrlsMixin from plugin.base.integration.UrlsMixin import UrlsMixin
from plugin.base.integration.ValidationMixin import ValidationMixin from plugin.base.integration.ValidationMixin import ValidationMixin
from plugin.base.label.mixins import LabelPrintingMixin from plugin.base.label.mixins import LabelPrintingMixin
@@ -38,6 +39,7 @@ __all__ = [
'ScheduleMixin', 'ScheduleMixin',
'SettingsMixin', 'SettingsMixin',
'SupplierBarcodeMixin', 'SupplierBarcodeMixin',
'TransitionMixin',
'UrlsMixin', 'UrlsMixin',
'UserInterfaceMixin', 'UserInterfaceMixin',
'ValidationMixin', 'ValidationMixin',

View File

@@ -74,6 +74,7 @@ class PluginMixinEnum(StringEnum):
SCHEDULE = 'schedule' SCHEDULE = 'schedule'
SETTINGS = 'settings' SETTINGS = 'settings'
SETTINGS_CONTENT = 'settingscontent' SETTINGS_CONTENT = 'settingscontent'
STATE_TRANSITION = 'statetransition'
SUPPLIER_BARCODE = 'supplier-barcode' SUPPLIER_BARCODE = 'supplier-barcode'
URLS = 'urls' URLS = 'urls'
USER_INTERFACE = 'ui' USER_INTERFACE = 'ui'

View File

@@ -1,21 +1,29 @@
"""Sample implementation of state transition implementation.""" """Sample implementation of state transition implementation."""
import warnings
from django.core.exceptions import ValidationError
from common.notifications import trigger_notification from common.notifications import trigger_notification
from generic.states import TransitionMethod from generic.states import TransitionMethod
from order.models import ReturnOrder from order.models import ReturnOrder
from order.status_codes import ReturnOrderStatus from order.status_codes import ReturnOrderStatus
from plugin import InvenTreePlugin from plugin import InvenTreePlugin
from plugin.mixins import SettingsMixin, TransitionMixin
class SampleTransitionPlugin(InvenTreePlugin): class SampleTransitionPlugin(TransitionMixin, InvenTreePlugin):
"""A sample plugin which shows how state transitions might be implemented.""" """A sample plugin which shows how state transitions might be implemented."""
NAME = 'SampleTransitionPlugin' NAME = 'SampleTransitionPlugin'
SLUG = 'sample-transition'
class ReturnChangeHandler(TransitionMethod): class ReturnChangeHandler(TransitionMethod):
"""Transition method for PurchaseOrder objects.""" """Transition method for ReturnOrder objects."""
def transition(current_state, target_state, instance, default_action, **kwargs): # noqa: N805 def transition(
self, current_state, target_state, instance, default_action, **kwargs
) -> bool:
"""Example override function for state transition.""" """Example override function for state transition."""
# Only act on ReturnOrders that should be completed # Only act on ReturnOrders that should be completed
if ( if (
@@ -26,15 +34,71 @@ class SampleTransitionPlugin(InvenTreePlugin):
# Only allow proceeding if the return order has a responsible user assigned # Only allow proceeding if the return order has a responsible user assigned
if not instance.responsible: if not instance.responsible:
msg = 'Return order without responsible owner can not be completed!'
# Trigger whoever created the return order # Trigger whoever created the return order
instance.created_by instance.created_by
trigger_notification( trigger_notification(
instance, instance,
'sampel_123_456', 'sampel_123_456',
targets=[instance.created_by], targets=[instance.created_by],
context={ context={'message': msg},
'message': 'Return order without responsible owner can not be completed!'
},
) )
return True # True means nothing will happen
raise ValidationError(msg)
return False # Do not act return False # Do not act
TRANSITION_HANDLERS = [ReturnChangeHandler()]
class BrokenTransitionPlugin(SettingsMixin, TransitionMixin, InvenTreePlugin):
"""An intentionally broken plugin to test error handling."""
NAME = 'BrokenTransitionPlugin'
SLUG = 'sample-broken-transition'
SETTINGS = {
'BROKEN_GET_METHOD': {
'name': 'Broken Get Method',
'description': 'If set, the get_transition_handlers method will raise an error.',
'validator': bool,
'default': False,
},
'WRONG_RETURN_TYPE': {
'name': 'Wrong Return Type',
'description': 'If set, the get_transition_handlers method will return an incorrect type.',
'validator': bool,
'default': False,
},
'WRONG_RETURN_VALUE': {
'name': 'Wrong Return Value',
'description': 'If set, the get_transition_handlers method will return an incorrect value.',
'validator': bool,
'default': False,
},
}
@property
def has_transition_handlers(self) -> bool:
"""Ensure that this plugin always has handlers."""
return True
def get_transition_handlers(self) -> list[TransitionMethod]:
"""Return transition handlers for the given instance."""
warnings.warn(
'get_transition_handlers is intentionally broken in this plugin',
stacklevel=2,
)
if self.get_setting('BROKEN_GET_METHOD', backup_value=False, cache=False):
raise ValueError('This is a broken transition plugin!')
if self.get_setting('WRONG_RETURN_TYPE', backup_value=False, cache=False):
return 'This is not a list of handlers!'
if self.get_setting('WRONG_RETURN_VALUE', backup_value=False, cache=False):
return [1, 2, 3]
# Return a valid handler list (empty)
return []