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:
16
docs/docs/plugins/mixins/transition.md
Normal file
16
docs/docs/plugins/mixins/transition.md
Normal 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") }}).
|
@@ -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
|
||||||
|
@@ -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
|
||||||
|
@@ -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
|
||||||
|
@@ -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',
|
|
||||||
]
|
]
|
||||||
|
@@ -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])
|
||||||
|
)
|
||||||
|
@@ -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)
|
||||||
|
@@ -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
|
@@ -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',
|
||||||
|
@@ -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'
|
||||||
|
@@ -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 []
|
||||||
|
Reference in New Issue
Block a user