mirror of
https://github.com/inventree/InvenTree.git
synced 2026-08-13 00:46:15 +00:00
feat(backend): switch to a more formal FSM approach (#12507)
* full fsm implementation closes https://github.com/inventree/InvenTree/issues/12314 based on https://github.com/matmair/InvenTree/pull/721 * update assertations * refactor to reduce duplication * move for cleaner diff * more moving stuff around * fix assingment * remove skip * merge test classes * re-enable transition plugin tests * fix docstrings * add depreciation warning * fix type * nitpicks * small cleanup * ensure we alwas pass a str * add backport * fix marker position * full fsm implementation closes https://github.com/inventree/InvenTree/issues/12314 based on https://github.com/matmair/InvenTree/pull/721 * update assertations * refactor to reduce duplication * move for cleaner diff * more moving stuff around * fix assingment * remove skip * merge test classes * re-enable transition plugin tests * fix docstrings * add depreciation warning * fix type * nitpicks * small cleanup * ensure we alwas pass a str * add backport * fix marker position * fix ty issue * ignore this corner case * ensure invalid transitions can raise a nice validation error * compact code * add depreciation mark * make raise_error default (#754) * fix merge * adjust test as this is now not a no-op but a raised error * fix assertations * fix test to not take a broken path * remove unused test statements * converge * add test branch for actually working transition * reduce uneeded code * ignore depreceated methods * fix missing coverage * add prefetch * reduce diff
This commit is contained in:
@@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Breaking Changes
|
### Breaking Changes
|
||||||
|
|
||||||
|
- [#12507](https://github.com/inventree/InvenTree/pull/12507) calling an invalid or repeated state transition now raises a ValidationError. Plugins implementing state transitions should evaluate the PR and adapt their usage of transitions to gain the new safeguards.
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
@@ -41,7 +41,14 @@ from build.validators import (
|
|||||||
from common.models import ProjectCode
|
from common.models import ProjectCode
|
||||||
from common.settings import get_global_setting
|
from common.settings import get_global_setting
|
||||||
from generic.enums import StringEnum
|
from generic.enums import StringEnum
|
||||||
from generic.states import StateTransitionMixin, StatusCodeMixin
|
from generic.states import (
|
||||||
|
Deprecations,
|
||||||
|
StateTransitionMixin,
|
||||||
|
StatusCodeMixin,
|
||||||
|
can_proceed,
|
||||||
|
deprecated,
|
||||||
|
inventree_transition,
|
||||||
|
)
|
||||||
from InvenTree.helpers_db import bulk_create_and_fetch
|
from InvenTree.helpers_db import bulk_create_and_fetch
|
||||||
from plugin.events import bulk_trigger_event, trigger_event
|
from plugin.events import bulk_trigger_event, trigger_event
|
||||||
from stock.events import StockEvents
|
from stock.events import StockEvents
|
||||||
@@ -959,41 +966,21 @@ class Build(
|
|||||||
# which point to this Build Order
|
# which point to this Build Order
|
||||||
self.allocated_stock.all().delete()
|
self.allocated_stock.all().delete()
|
||||||
|
|
||||||
@transaction.atomic
|
# region fsm
|
||||||
|
@inventree_transition(
|
||||||
|
field=status,
|
||||||
|
source=[BuildStatus.PENDING, BuildStatus.PRODUCTION, BuildStatus.ON_HOLD],
|
||||||
|
target=BuildStatus.COMPLETE,
|
||||||
|
)
|
||||||
def complete_build(self, user: User, trim_allocated_stock: bool = False):
|
def complete_build(self, user: User, trim_allocated_stock: bool = False):
|
||||||
"""Mark this build as complete.
|
"""Transition this Build to COMPLETE status.
|
||||||
|
|
||||||
Arguments:
|
Arguments:
|
||||||
user: The user who is completing the build
|
user: The user who is completing the build
|
||||||
trim_allocated_stock: If True, trim any allocated stock
|
trim_allocated_stock: If True, trim any allocated stock
|
||||||
"""
|
"""
|
||||||
return self.handle_transition(
|
|
||||||
self.status,
|
|
||||||
BuildStatus.COMPLETE.value,
|
|
||||||
self,
|
|
||||||
self._action_complete,
|
|
||||||
user=user,
|
|
||||||
trim_allocated_stock=trim_allocated_stock,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _action_complete(self, *args, **kwargs):
|
|
||||||
"""Action to be taken when a build is completed."""
|
|
||||||
import build.tasks
|
import build.tasks
|
||||||
|
|
||||||
# Lock this build against concurrent transitions, and re-read the status
|
|
||||||
# from the database. Without this, two simultaneous completion requests
|
|
||||||
# could both offload the completion task (duplicate events and
|
|
||||||
# notifications; the stock-consuming side effects are themselves
|
|
||||||
# guarded against double-processing
|
|
||||||
self.status = Build.objects.select_for_update().get(pk=self.pk).status
|
|
||||||
|
|
||||||
if self.status == BuildStatus.COMPLETE.value:
|
|
||||||
return
|
|
||||||
|
|
||||||
trim_allocated_stock = kwargs.pop('trim_allocated_stock', False)
|
|
||||||
user = kwargs.pop('user', None)
|
|
||||||
|
|
||||||
# Prevent completion if there are open child builds
|
|
||||||
if (
|
if (
|
||||||
get_global_setting('BUILDORDER_REQUIRE_CLOSED_CHILDS')
|
get_global_setting('BUILDORDER_REQUIRE_CLOSED_CHILDS')
|
||||||
and self.has_open_child_builds
|
and self.has_open_child_builds
|
||||||
@@ -1016,102 +1003,61 @@ class Build(
|
|||||||
group='build',
|
group='build',
|
||||||
)
|
)
|
||||||
|
|
||||||
@transaction.atomic
|
@inventree_transition(
|
||||||
|
field=status,
|
||||||
|
source=[BuildStatus.PENDING, BuildStatus.ON_HOLD],
|
||||||
|
target=BuildStatus.PRODUCTION,
|
||||||
|
event=BuildEvents.ISSUED,
|
||||||
|
)
|
||||||
def issue_build(self):
|
def issue_build(self):
|
||||||
"""Mark the Build as IN PRODUCTION.
|
"""Transition this Build to PRODUCTION status.
|
||||||
|
|
||||||
Args:
|
The build must currently be PENDING or ON_HOLD.
|
||||||
user: The user who is issuing the build
|
|
||||||
"""
|
"""
|
||||||
return self.handle_transition(
|
from build.tasks import check_build_stock
|
||||||
self.status, BuildStatus.PENDING.value, self, self._action_issue
|
|
||||||
|
# Run checks on required parts
|
||||||
|
InvenTree.tasks.offload_task(
|
||||||
|
check_build_stock, self, group='build', force_async=True
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
@deprecated(Deprecations.CAN_PROCEED, version='1.5.0')
|
||||||
def can_issue(self) -> bool:
|
def can_issue(self) -> bool:
|
||||||
"""Returns True if this BuildOrder can be issued."""
|
"""Returns True if this BuildOrder can be issued."""
|
||||||
return self.status in [BuildStatus.PENDING.value, BuildStatus.ON_HOLD.value]
|
return can_proceed(self.issue_build)
|
||||||
|
|
||||||
def _action_issue(self, *args, **kwargs):
|
@inventree_transition(
|
||||||
"""Perform the action to mark this order as PRODUCTION."""
|
field=status,
|
||||||
# Lock this build against concurrent transitions, and re-read the status
|
source=[BuildStatus.PENDING, BuildStatus.PRODUCTION],
|
||||||
# from the database. Without this, two simultaneous requests can both
|
target=BuildStatus.ON_HOLD,
|
||||||
# observe an eligible status, and each would run the issue side effects
|
event=BuildEvents.HOLD,
|
||||||
# (duplicate events and offloaded stock-check tasks).
|
)
|
||||||
self.status = Build.objects.select_for_update().get(pk=self.pk).status
|
|
||||||
|
|
||||||
if self.can_issue:
|
|
||||||
self.status = BuildStatus.PRODUCTION.value
|
|
||||||
self.save()
|
|
||||||
|
|
||||||
trigger_event(BuildEvents.ISSUED, id=self.pk)
|
|
||||||
|
|
||||||
from build.tasks import check_build_stock
|
|
||||||
|
|
||||||
# Run checks on required parts
|
|
||||||
InvenTree.tasks.offload_task(
|
|
||||||
check_build_stock, self, group='build', force_async=True
|
|
||||||
)
|
|
||||||
|
|
||||||
@transaction.atomic
|
|
||||||
def hold_build(self):
|
def hold_build(self):
|
||||||
"""Mark the Build as ON HOLD."""
|
"""Transition this Build to ON_HOLD status.
|
||||||
return self.handle_transition(
|
|
||||||
self.status, BuildStatus.ON_HOLD.value, self, self._action_hold
|
The build must currently be PENDING or PRODUCTION.
|
||||||
)
|
"""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
@deprecated(Deprecations.CAN_PROCEED, version='1.5.0')
|
||||||
def can_hold(self) -> bool:
|
def can_hold(self) -> bool:
|
||||||
"""Returns True if this BuildOrder can be placed on hold."""
|
"""Returns True if this BuildOrder can be placed on hold."""
|
||||||
return self.status in [BuildStatus.PENDING.value, BuildStatus.PRODUCTION.value]
|
return can_proceed(self.hold_build)
|
||||||
|
|
||||||
def _action_hold(self, *args, **kwargs):
|
@inventree_transition(
|
||||||
"""Action to be taken when a build is placed on hold."""
|
field=status,
|
||||||
# Lock this build against concurrent transitions, and re-read the status
|
source=[BuildStatus.PENDING, BuildStatus.PRODUCTION, BuildStatus.ON_HOLD],
|
||||||
# from the database. Without this, two simultaneous requests can both
|
target=BuildStatus.CANCELLED,
|
||||||
# observe an eligible status, and each would run the hold side effects
|
)
|
||||||
# (duplicate events).
|
def cancel_build(self, user=None, **kwargs):
|
||||||
self.status = Build.objects.select_for_update().get(pk=self.pk).status
|
"""Transition this Build to CANCELLED status.
|
||||||
|
|
||||||
if self.can_hold:
|
Offloads expensive cleanup operations (de-allocating stock, removing
|
||||||
self.status = BuildStatus.ON_HOLD.value
|
incomplete outputs) to a background task.
|
||||||
self.save()
|
|
||||||
|
|
||||||
trigger_event(BuildEvents.HOLD, id=self.pk)
|
|
||||||
|
|
||||||
@transaction.atomic
|
|
||||||
def cancel_build(self, user, **kwargs):
|
|
||||||
"""Mark the Build as CANCELLED.
|
|
||||||
|
|
||||||
- Delete any pending BuildItem objects (but do not remove items from stock)
|
|
||||||
- Set build status to CANCELLED
|
|
||||||
- Save the Build object
|
|
||||||
"""
|
"""
|
||||||
return self.handle_transition(
|
|
||||||
self.status,
|
|
||||||
BuildStatus.CANCELLED.value,
|
|
||||||
self,
|
|
||||||
self._action_cancel,
|
|
||||||
user=user,
|
|
||||||
**kwargs,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _action_cancel(self, *args, **kwargs):
|
|
||||||
"""Action to be taken when a build is cancelled."""
|
|
||||||
import build.tasks
|
import build.tasks
|
||||||
|
|
||||||
# Lock this build against concurrent transitions, and re-read the status
|
|
||||||
# from the database. Without this, two simultaneous cancellation requests
|
|
||||||
# could both offload the cancellation task (duplicate events and
|
|
||||||
# notifications; the stock-consuming side effects are themselves
|
|
||||||
# guarded against double-processing
|
|
||||||
self.status = Build.objects.select_for_update().get(pk=self.pk).status
|
|
||||||
|
|
||||||
if self.status == BuildStatus.CANCELLED.value:
|
|
||||||
return
|
|
||||||
|
|
||||||
user = kwargs.pop('user', None)
|
|
||||||
|
|
||||||
remove_allocated_stock = kwargs.get('remove_allocated_stock', False)
|
remove_allocated_stock = kwargs.get('remove_allocated_stock', False)
|
||||||
remove_incomplete_outputs = kwargs.get('remove_incomplete_outputs', False)
|
remove_incomplete_outputs = kwargs.get('remove_incomplete_outputs', False)
|
||||||
|
|
||||||
@@ -1129,8 +1075,7 @@ class Build(
|
|||||||
self.completion_date = InvenTree.helpers.current_date()
|
self.completion_date = InvenTree.helpers.current_date()
|
||||||
self.completed_by = user
|
self.completed_by = user
|
||||||
|
|
||||||
self.status = BuildStatus.CANCELLED.value
|
# endregion fsm
|
||||||
self.save()
|
|
||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def deallocate_stock(self, build_line=None, output=None):
|
def deallocate_stock(self, build_line=None, output=None):
|
||||||
@@ -2139,7 +2084,7 @@ class Build(
|
|||||||
"""Rebuild required quantity field for each BuildLine object."""
|
"""Rebuild required quantity field for each BuildLine object."""
|
||||||
lines_to_update = []
|
lines_to_update = []
|
||||||
|
|
||||||
for line in self.build_lines.all():
|
for line in self.build_lines.select_related('bom_item').all():
|
||||||
line.quantity = line.bom_item.get_required_quantity(self.quantity)
|
line.quantity = line.bom_item.get_required_quantity(self.quantity)
|
||||||
lines_to_update.append(line)
|
lines_to_update.append(line)
|
||||||
|
|
||||||
|
|||||||
@@ -2365,7 +2365,7 @@ class BuildConsumeTest(BuildAPITest):
|
|||||||
{},
|
{},
|
||||||
expected_code=201,
|
expected_code=201,
|
||||||
benchmark=True,
|
benchmark=True,
|
||||||
max_query_count=250,
|
max_query_count=180,
|
||||||
max_query_time=1.5,
|
max_query_time=1.5,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -619,9 +619,10 @@ class BuildTest(BuildTestBase):
|
|||||||
# cancellation must be skipped based on the database state
|
# cancellation must be skipped based on the database state
|
||||||
self.assertEqual(build_b.status, status.BuildStatus.PRODUCTION)
|
self.assertEqual(build_b.status, status.BuildStatus.PRODUCTION)
|
||||||
|
|
||||||
with mock.patch('build.models.trigger_event') as trigger:
|
with self.assertRaises(ValidationError) as err:
|
||||||
build_b.cancel_build(None)
|
build_b.cancel_build(None)
|
||||||
trigger.assert_not_called()
|
|
||||||
|
self.assertIn('Build Order is already Cancelled', str(err.exception))
|
||||||
|
|
||||||
self.build.refresh_from_db()
|
self.build.refresh_from_db()
|
||||||
self.assertEqual(self.build.status, status.BuildStatus.CANCELLED)
|
self.assertEqual(self.build.status, status.BuildStatus.CANCELLED)
|
||||||
|
|||||||
@@ -6,15 +6,23 @@ There is a rendered state for each state value. The rendered state is used for d
|
|||||||
States can be extended with custom options for each InvenTree instance - those options are stored in the database and need to link back to state values.
|
States can be extended with custom options for each InvenTree instance - those options are stored in the database and need to link back to state values.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from django_fsm import RETURN_VALUE, can_proceed
|
||||||
|
|
||||||
from . import fields
|
from . import fields
|
||||||
|
from .deprecations import Deprecations, deprecated
|
||||||
from .states import ColorEnum, StatusCode, StatusCodeMixin
|
from .states import ColorEnum, StatusCode, StatusCodeMixin
|
||||||
from .transition import StateTransitionMixin, TransitionMethod
|
from .transition import StateTransitionMixin, TransitionMethod, inventree_transition
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
'RETURN_VALUE', # django_fsm import
|
||||||
'ColorEnum',
|
'ColorEnum',
|
||||||
|
'Deprecations',
|
||||||
'StateTransitionMixin',
|
'StateTransitionMixin',
|
||||||
'StatusCode',
|
'StatusCode',
|
||||||
'StatusCodeMixin',
|
'StatusCodeMixin',
|
||||||
'TransitionMethod',
|
'TransitionMethod',
|
||||||
|
'can_proceed', # django_fsm import
|
||||||
|
'deprecated',
|
||||||
'fields',
|
'fields',
|
||||||
|
'inventree_transition',
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""Helper for deprecating old implementation details."""
|
||||||
|
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
# py (3, 12) does not ship with depreciation decorator, so we need to import it from typing_extensions
|
||||||
|
try:
|
||||||
|
from warnings import deprecated as warn_deprecated # ty: ignore[unresolved-import]
|
||||||
|
except ImportError:
|
||||||
|
from typing_extensions import deprecated as warn_deprecated
|
||||||
|
|
||||||
|
|
||||||
|
class Deprecations(Enum):
|
||||||
|
"""Deprecations for states."""
|
||||||
|
|
||||||
|
CAN_PROCEED = 'Use can_proceed directly'
|
||||||
|
|
||||||
|
|
||||||
|
class deprecated(warn_deprecated): # noqa: N801
|
||||||
|
"""Deprecation decorator for state transition methods."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self, message: Any | str, version: Optional[str] = None, *args, **kwargs
|
||||||
|
):
|
||||||
|
"""Initialize the decorator with a deprecation reason."""
|
||||||
|
self.version = version
|
||||||
|
super().__init__(str(message), *args, **kwargs)
|
||||||
@@ -9,6 +9,7 @@ from django.db import models
|
|||||||
from django.utils.encoding import force_str
|
from django.utils.encoding import force_str
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from django_fsm import FSMFieldMixin
|
||||||
from drf_spectacular.types import OpenApiTypes
|
from drf_spectacular.types import OpenApiTypes
|
||||||
from drf_spectacular.utils import extend_schema_field
|
from drf_spectacular.utils import extend_schema_field
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
@@ -90,11 +91,19 @@ class ExtraCustomChoiceField(CustomChoiceField):
|
|||||||
return super().to_representation(value) or value
|
return super().to_representation(value) or value
|
||||||
|
|
||||||
|
|
||||||
class InvenTreeCustomStatusModelField(models.PositiveIntegerField):
|
class InvenTreeCustomStatusModelField(FSMFieldMixin, models.PositiveIntegerField):
|
||||||
"""Custom model field for extendable status codes.
|
"""Custom model field for extendable status codes.
|
||||||
|
|
||||||
Adds a secondary *_custom_key field to the model which can be used to store additional status information.
|
Extends Django's PositiveIntegerField with FSM (Finite State Machine) support
|
||||||
Models using this model field must also include the InvenTreeCustomStatusSerializerMixin in all serializers that create or update the value.
|
via django-fsm-2, enabling use of the @transition decorator directly on model
|
||||||
|
methods that use this field.
|
||||||
|
|
||||||
|
Adds a secondary *_custom_key field to the model which can be used to store
|
||||||
|
additional status information.
|
||||||
|
|
||||||
|
Models using this model field must also include the
|
||||||
|
InvenTreeCustomStatusSerializerMixin in all serializers that create or update
|
||||||
|
the value.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
@@ -109,12 +118,18 @@ class InvenTreeCustomStatusModelField(models.PositiveIntegerField):
|
|||||||
validators.append(CustomStatusCodeValidator(status_class=self.status_class))
|
validators.append(CustomStatusCodeValidator(status_class=self.status_class))
|
||||||
|
|
||||||
kwargs['validators'] = validators
|
kwargs['validators'] = validators
|
||||||
|
# FSM protection is disabled by default; direct status assignment is still allowed
|
||||||
|
kwargs.setdefault('protected', False)
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
def deconstruct(self):
|
def deconstruct(self):
|
||||||
"""Deconstruct the field for migrations."""
|
"""Deconstruct the field for migrations.
|
||||||
name, path, args, kwargs = super().deconstruct()
|
|
||||||
|
|
||||||
|
FSM-specific kwargs (protected) are excluded to avoid spurious migrations,
|
||||||
|
since they do not affect the database schema.
|
||||||
|
"""
|
||||||
|
name, path, args, kwargs = super().deconstruct()
|
||||||
|
kwargs.pop('protected', None)
|
||||||
return name, path, args, kwargs
|
return name, path, args, kwargs
|
||||||
|
|
||||||
def contribute_to_class(self, cls, name):
|
def contribute_to_class(self, cls, name):
|
||||||
@@ -182,7 +197,7 @@ class InvenTreeCustomStatusModelField(models.PositiveIntegerField):
|
|||||||
|
|
||||||
|
|
||||||
class ExtraInvenTreeCustomStatusModelField(models.PositiveIntegerField):
|
class ExtraInvenTreeCustomStatusModelField(models.PositiveIntegerField):
|
||||||
"""Custom field used to detect custom extenteded fields.
|
"""Custom field used to detect custom extended fields.
|
||||||
|
|
||||||
This is not intended to be used directly, if you want to support custom states in your model use InvenTreeCustomStatusModelField.
|
This is not intended to be used directly, if you want to support custom states in your model use InvenTreeCustomStatusModelField.
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ from typing import Optional
|
|||||||
logger = logging.getLogger('inventree')
|
logger = logging.getLogger('inventree')
|
||||||
|
|
||||||
|
|
||||||
class BaseEnum(enum.IntEnum): # noqa: PLW1641
|
class BaseEnum(enum.IntEnum):
|
||||||
"""An `Enum` capabile of having its members have docstrings.
|
"""An `Enum` capable of having its members have docstrings.
|
||||||
|
|
||||||
Based on https://stackoverflow.com/questions/19330460/how-do-i-put-docstrings-on-enums
|
Based on https://stackoverflow.com/questions/19330460/how-do-i-put-docstrings-on-enums
|
||||||
"""
|
"""
|
||||||
@@ -42,6 +42,14 @@ class BaseEnum(enum.IntEnum): # noqa: PLW1641
|
|||||||
|
|
||||||
return super().__eq__(obj)
|
return super().__eq__(obj)
|
||||||
|
|
||||||
|
def __hash__(self):
|
||||||
|
"""Return integer hash so enum members are usable as dict keys and in sets.
|
||||||
|
|
||||||
|
Required because we define ``__eq__``: Python sets ``__hash__ = None``
|
||||||
|
when ``__eq__`` is overridden without a matching ``__hash__``.
|
||||||
|
"""
|
||||||
|
return hash(self.value)
|
||||||
|
|
||||||
def __ne__(self, obj):
|
def __ne__(self, obj):
|
||||||
"""Override inequality operator to allow comparison with int."""
|
"""Override inequality operator to allow comparison with int."""
|
||||||
if type(self) is type(obj):
|
if type(self) is type(obj):
|
||||||
@@ -262,7 +270,7 @@ class ColorEnum(Enum):
|
|||||||
class StatusCodeMixin:
|
class StatusCodeMixin:
|
||||||
"""Mixin class which handles custom 'status' fields.
|
"""Mixin class which handles custom 'status' fields.
|
||||||
|
|
||||||
- Implements a 'set_stutus' method which can be used to set the status of an object
|
- Implements a 'set_status' method which can be used to set the status of an object
|
||||||
- Implements a 'get_status' method which can be used to retrieve the status of an object
|
- Implements a 'get_status' method which can be used to retrieve the status of an object
|
||||||
|
|
||||||
This mixin assumes that the implementing class has a 'status' field,
|
This mixin assumes that the implementing class has a 'status' field,
|
||||||
|
|||||||
@@ -2,22 +2,158 @@
|
|||||||
|
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
|
|
||||||
|
from generic.states import can_proceed
|
||||||
from InvenTree.unit_test import InvenTreeTestCase
|
from InvenTree.unit_test import InvenTreeTestCase
|
||||||
from order.models import ReturnOrder
|
from order.models import PurchaseOrder, ReturnOrder, SalesOrder, TransferOrder
|
||||||
from order.status_codes import ReturnOrderStatus
|
from order.status_codes import (
|
||||||
|
PurchaseOrderStatus,
|
||||||
|
ReturnOrderStatus,
|
||||||
|
SalesOrderStatus,
|
||||||
|
TransferOrderStatus,
|
||||||
|
)
|
||||||
from plugin import registry
|
from plugin import registry
|
||||||
|
from users.models import Owner
|
||||||
|
|
||||||
|
|
||||||
class TransitionTests(InvenTreeTestCase):
|
class TransitionTests(InvenTreeTestCase):
|
||||||
"""Tests for custom state transition logic."""
|
"""Tests for custom state transition logic."""
|
||||||
|
|
||||||
fixtures = ['company', 'return_order', 'part', 'stock', 'location', 'category']
|
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
"""Set up the test environment."""
|
"""Set up the test environment."""
|
||||||
super().setUp()
|
super().setUp()
|
||||||
self.ensurePluginsLoaded()
|
self.ensurePluginsLoaded()
|
||||||
|
|
||||||
|
fixtures = [
|
||||||
|
'company',
|
||||||
|
'supplier_part',
|
||||||
|
'category',
|
||||||
|
'part',
|
||||||
|
'location',
|
||||||
|
'stock',
|
||||||
|
'order',
|
||||||
|
'sales_order',
|
||||||
|
'return_order',
|
||||||
|
'transfer_order',
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_fsm_decorator_applied_to_purchase_order(self):
|
||||||
|
"""Verify @inventree_transition metadata is attached to PurchaseOrder methods."""
|
||||||
|
# Methods decorated with @inventree_transition expose _django_fsm metadata
|
||||||
|
self.assertTrue(hasattr(PurchaseOrder.place_order, '_django_fsm'))
|
||||||
|
self.assertTrue(hasattr(PurchaseOrder.complete_order, '_django_fsm'))
|
||||||
|
self.assertTrue(hasattr(PurchaseOrder.hold_order, '_django_fsm'))
|
||||||
|
self.assertTrue(hasattr(PurchaseOrder.cancel_order, '_django_fsm'))
|
||||||
|
|
||||||
|
def test_fsm_can_proceed_purchase_order(self):
|
||||||
|
"""Test can_proceed() reflects current state correctly for PurchaseOrder."""
|
||||||
|
po = PurchaseOrder.objects.filter(
|
||||||
|
status=PurchaseOrderStatus.PENDING.value
|
||||||
|
).first()
|
||||||
|
assert po
|
||||||
|
|
||||||
|
# A PENDING order can be placed or cancelled, but not completed
|
||||||
|
self.assertTrue(po.can_issue)
|
||||||
|
self.assertTrue(po.can_cancel)
|
||||||
|
self.assertFalse(can_proceed(po.complete_order))
|
||||||
|
self.assertTrue(po.is_open)
|
||||||
|
|
||||||
|
# depreceted methods
|
||||||
|
self.assertTrue(po.can_hold)
|
||||||
|
|
||||||
|
def test_fsm_purchase_order_transitions(self):
|
||||||
|
"""Test that PurchaseOrder transitions work correctly via @inventree_transition."""
|
||||||
|
po = PurchaseOrder.objects.filter(
|
||||||
|
status=PurchaseOrderStatus.PENDING.value
|
||||||
|
).first()
|
||||||
|
assert po
|
||||||
|
|
||||||
|
# Place the order (PENDING → PLACED)
|
||||||
|
result = po.place_order()
|
||||||
|
self.assertTrue(result)
|
||||||
|
po.refresh_from_db()
|
||||||
|
self.assertEqual(po.status, PurchaseOrderStatus.PLACED.value)
|
||||||
|
|
||||||
|
# Hold the order (PLACED → ON_HOLD)
|
||||||
|
result = po.hold_order()
|
||||||
|
self.assertTrue(result)
|
||||||
|
po.refresh_from_db()
|
||||||
|
self.assertEqual(po.status, PurchaseOrderStatus.ON_HOLD.value)
|
||||||
|
|
||||||
|
# Place again from ON_HOLD (ON_HOLD → PLACED)
|
||||||
|
result = po.place_order()
|
||||||
|
self.assertTrue(result)
|
||||||
|
po.refresh_from_db()
|
||||||
|
self.assertEqual(po.status, PurchaseOrderStatus.PLACED.value)
|
||||||
|
|
||||||
|
# Cancel the order (PLACED → CANCELLED)
|
||||||
|
result = po.cancel_order()
|
||||||
|
self.assertTrue(result)
|
||||||
|
po.refresh_from_db()
|
||||||
|
self.assertEqual(po.status, PurchaseOrderStatus.CANCELLED.value)
|
||||||
|
|
||||||
|
def test_fsm_sale_order_transitions(self):
|
||||||
|
"""Test that SaleOrder transitions work correctly via @inventree_transition."""
|
||||||
|
so = SalesOrder.objects.filter(status=SalesOrderStatus.PENDING.value).first()
|
||||||
|
assert so
|
||||||
|
|
||||||
|
# deprecated methods
|
||||||
|
self.assertTrue(so.can_issue)
|
||||||
|
self.assertTrue(so.can_hold)
|
||||||
|
self.assertTrue(so.can_cancel)
|
||||||
|
|
||||||
|
def test_fsm_invalid_transition_returns_false(self):
|
||||||
|
"""Test that an invalid transition returns False (backward-compatible behaviour)."""
|
||||||
|
po = PurchaseOrder.objects.filter(
|
||||||
|
status=PurchaseOrderStatus.CANCELLED.value
|
||||||
|
).first()
|
||||||
|
assert po
|
||||||
|
|
||||||
|
# Attempting to place a cancelled order must raise
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
po.place_order()
|
||||||
|
|
||||||
|
# The order status must remain CANCELLED
|
||||||
|
po.refresh_from_db()
|
||||||
|
self.assertEqual(po.status, PurchaseOrderStatus.CANCELLED.value)
|
||||||
|
|
||||||
|
def test_fsm_return_order_transitions(self):
|
||||||
|
"""Test that ReturnOrder transitions work correctly via @inventree_transition."""
|
||||||
|
ro = ReturnOrder.objects.filter(
|
||||||
|
status=ReturnOrderStatus.IN_PROGRESS.value
|
||||||
|
).first()
|
||||||
|
assert ro
|
||||||
|
|
||||||
|
# Can complete an IN_PROGRESS return order
|
||||||
|
self.assertTrue(can_proceed(ro.complete_order))
|
||||||
|
|
||||||
|
# Cannot issue an IN_PROGRESS return order (already issued)
|
||||||
|
self.assertFalse(can_proceed(ro.issue_order))
|
||||||
|
|
||||||
|
# deprecated methods
|
||||||
|
self.assertTrue(ro.can_hold)
|
||||||
|
self.assertTrue(ro.can_cancel)
|
||||||
|
self.assertFalse(ro.can_issue)
|
||||||
|
|
||||||
|
def test_fsm_transferorder(self):
|
||||||
|
"""Test that TransferOrder transitions work correctly via @inventree_transition."""
|
||||||
|
to = TransferOrder.objects.filter(
|
||||||
|
status=TransferOrderStatus.PENDING.value
|
||||||
|
).first()
|
||||||
|
assert to
|
||||||
|
|
||||||
|
self.assertTrue(to.can_issue)
|
||||||
|
self.assertTrue(to.can_hold)
|
||||||
|
self.assertTrue(to.can_cancel)
|
||||||
|
|
||||||
|
def test_fsm_can_issue_property(self):
|
||||||
|
"""Test the can_issue property delegates to can_proceed."""
|
||||||
|
po = PurchaseOrder.objects.filter(
|
||||||
|
status=PurchaseOrderStatus.PENDING.value
|
||||||
|
).first()
|
||||||
|
assert po
|
||||||
|
|
||||||
|
self.assertEqual(po.can_issue, can_proceed(po.place_order))
|
||||||
|
|
||||||
def test_return_order(self):
|
def test_return_order(self):
|
||||||
"""Test transition of a return order."""
|
"""Test transition of a return order."""
|
||||||
# Ensure plugin is enabled
|
# Ensure plugin is enabled
|
||||||
@@ -36,6 +172,13 @@ class TransitionTests(InvenTreeTestCase):
|
|||||||
str(e.exception),
|
str(e.exception),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
ro.responsible = Owner.create(obj=self.user)
|
||||||
|
ro.save()
|
||||||
|
result = ro.complete_order()
|
||||||
|
self.assertEqual(result, '123#abc!')
|
||||||
|
# There should be no change in the status of the return order
|
||||||
|
self.assertEqual(ro.status, ReturnOrderStatus.IN_PROGRESS.value)
|
||||||
|
|
||||||
# Now disable the plugin
|
# Now disable the plugin
|
||||||
registry.set_plugin_state('sample-transition', False)
|
registry.set_plugin_state('sample-transition', False)
|
||||||
|
|
||||||
@@ -61,9 +204,6 @@ class TransitionTests(InvenTreeTestCase):
|
|||||||
ro = ReturnOrder.objects.get(pk=2)
|
ro = ReturnOrder.objects.get(pk=2)
|
||||||
self.assertEqual(ro.status, ReturnOrderStatus.IN_PROGRESS.value)
|
self.assertEqual(ro.status, ReturnOrderStatus.IN_PROGRESS.value)
|
||||||
|
|
||||||
# Transition to "ON HOLD" state
|
|
||||||
ro.hold_order()
|
|
||||||
|
|
||||||
# Ensure plugin starts in a known state
|
# Ensure plugin starts in a known state
|
||||||
plugin = registry.get_plugin('sample-broken-transition')
|
plugin = registry.get_plugin('sample-broken-transition')
|
||||||
plugin.set_setting('BROKEN_GET_METHOD', False)
|
plugin.set_setting('BROKEN_GET_METHOD', False)
|
||||||
@@ -76,7 +216,7 @@ class TransitionTests(InvenTreeTestCase):
|
|||||||
|
|
||||||
with self.assertWarnsMessage(UserWarning, msg):
|
with self.assertWarnsMessage(UserWarning, msg):
|
||||||
# No error should occur here
|
# No error should occur here
|
||||||
ro.complete_order()
|
ro.hold_order()
|
||||||
self.assertEqual(ro.status, ReturnOrderStatus.ON_HOLD.value)
|
self.assertEqual(ro.status, ReturnOrderStatus.ON_HOLD.value)
|
||||||
|
|
||||||
# No error should be logged
|
# No error should be logged
|
||||||
@@ -91,7 +231,7 @@ class TransitionTests(InvenTreeTestCase):
|
|||||||
ro.issue_order()
|
ro.issue_order()
|
||||||
self.assertEqual(ro.status, ReturnOrderStatus.IN_PROGRESS.value)
|
self.assertEqual(ro.status, ReturnOrderStatus.IN_PROGRESS.value)
|
||||||
|
|
||||||
# Ensure correct eroror was logged
|
# Ensure correct error was logged
|
||||||
self.assertIn('Invalid transition handler type: 1', str(cm.output[0]))
|
self.assertIn('Invalid transition handler type: 1', str(cm.output[0]))
|
||||||
|
|
||||||
# Now, enable the "WRONG_RETURN_TYPE" setting
|
# Now, enable the "WRONG_RETURN_TYPE" setting
|
||||||
|
|||||||
@@ -1,18 +1,138 @@
|
|||||||
"""Classes and functions for plugin controlled object state transitions."""
|
"""Classes and functions for plugin controlled object state transitions."""
|
||||||
|
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
|
from enum import Enum
|
||||||
|
from functools import wraps
|
||||||
|
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.db import transaction
|
||||||
from django.db.models import Model
|
from django.db.models import Model
|
||||||
|
|
||||||
import structlog
|
import structlog
|
||||||
|
from django_fsm import TransitionNotAllowed, transition
|
||||||
|
|
||||||
|
from plugin.events import trigger_event
|
||||||
|
|
||||||
|
from .deprecations import deprecated
|
||||||
|
|
||||||
logger = structlog.get_logger('inventree')
|
logger = structlog.get_logger('inventree')
|
||||||
|
|
||||||
|
|
||||||
class TransitionMethod:
|
def inventree_transition(
|
||||||
"""Base class for all transition classes.
|
field, source, target, event=None, refresh_field: bool = True, **extra
|
||||||
|
):
|
||||||
|
"""Decorator that combines ``@django_fsm.transition`` with plugin transitions.
|
||||||
|
|
||||||
Must implement a method called `transition` that takes both args and kwargs.
|
Wraps a model method so that:
|
||||||
|
* Source/target state validation is enforced via ``django_fsm.transition``.
|
||||||
|
* Plugin transitions are called automatically before the FSM transition executes.
|
||||||
|
* The model instance is saved after a successful transition.
|
||||||
|
* The whole operation is wrapped in a database transaction.
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
class MyOrder(StateTransitionMixin, models.Model):
|
||||||
|
status = InvenTreeCustomStatusModelField(
|
||||||
|
status_class=MyOrderStatus, ...
|
||||||
|
)
|
||||||
|
|
||||||
|
@inventree_transition(
|
||||||
|
field=status,
|
||||||
|
source=[MyOrderStatus.PENDING, MyOrderStatus.ON_HOLD],
|
||||||
|
target=MyOrderStatus.PLACED,
|
||||||
|
event=MyOrderEvents.PLACED,
|
||||||
|
refresh_field=False,
|
||||||
|
)
|
||||||
|
def place_order(self):
|
||||||
|
notify_responsible(...)
|
||||||
|
|
||||||
|
The ``can_<method>`` guard pattern can be preserved using ``can_proceed``::
|
||||||
|
|
||||||
|
@property
|
||||||
|
def can_issue(self) -> bool:
|
||||||
|
return can_proceed(self.place_order)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
field: The ``InvenTreeCustomStatusModelField`` (or its string name) that holds the state.
|
||||||
|
source: Allowed source state(s) for the transition.
|
||||||
|
target: Target state after the transition.
|
||||||
|
event: Optional event (name) to trigger after the transition. If not provided no event is triggered.
|
||||||
|
refresh_field: (default True) If True, the field is updated from the database before the transition.
|
||||||
|
**extra: Additional keyword arguments forwarded to `django_fsm.transition` (e.g. `conditions`, `on_error`, `permission`).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def decorator(func):
|
||||||
|
# Apply the django-fsm @transition decorator first.
|
||||||
|
fsm_func = transition(field=field, source=source, target=target, **extra)(func)
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
@wraps(func)
|
||||||
|
def wrapper(self, *args, **kwargs):
|
||||||
|
"""Ensure that transitions are handled correctly."""
|
||||||
|
if refresh_field:
|
||||||
|
try:
|
||||||
|
# Update the field from the database to avoid race conditions
|
||||||
|
current_obj = type(self).objects.select_for_update().get(pk=self.pk)
|
||||||
|
new_value = getattr(current_obj, field.name)
|
||||||
|
setattr(self, field.name, new_value)
|
||||||
|
except type(self).DoesNotExist: # pragma: no cover
|
||||||
|
raise ValidationError(
|
||||||
|
f'{self._meta.verbose_name} with pk={self.pk} does not exist in the database'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Run plugin transition handlers - if no step is taken the decorated method is called
|
||||||
|
if result := _run_plugin_transition_handlers(
|
||||||
|
self,
|
||||||
|
getattr(self, field.name),
|
||||||
|
target,
|
||||||
|
default_action=_noop_default_action,
|
||||||
|
):
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Execute the FSM-wrapped function. This validates the source state,
|
||||||
|
# runs the method body, and updates the field to the target state.
|
||||||
|
try:
|
||||||
|
fsm_func(self, *args, **kwargs)
|
||||||
|
except TransitionNotAllowed as exc:
|
||||||
|
# Preserve backward-compatible behaviour: an invalid transition
|
||||||
|
# (wrong source state, failed condition, or explicit raise inside
|
||||||
|
# the method body) returns False rather than raising.
|
||||||
|
if getattr(self, field.name) == target:
|
||||||
|
target_val = (
|
||||||
|
target.label
|
||||||
|
if isinstance(target, Enum) and hasattr(target, 'label')
|
||||||
|
else target
|
||||||
|
)
|
||||||
|
raise ValidationError(
|
||||||
|
f'{self._meta.verbose_name} is already {target_val}'
|
||||||
|
) from exc
|
||||||
|
raise ValidationError(
|
||||||
|
f'Invalid transition on {self._meta.verbose_name}.{field.name} (source value should be {source}, is {getattr(self, field.name)})'
|
||||||
|
) from exc
|
||||||
|
# Persist all changes (including the updated status field) to the DB.
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
# trigger event for the transition
|
||||||
|
if event:
|
||||||
|
trigger_event(event, id=self.pk)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Propagate the django-fsm metadata so that can_proceed() works correctly
|
||||||
|
# on the public wrapper method.
|
||||||
|
if hasattr(fsm_func, '_django_fsm'):
|
||||||
|
wrapper._django_fsm = fsm_func._django_fsm
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
class TransitionMethod:
|
||||||
|
"""Base class for all plugin-defined transition handler classes.
|
||||||
|
|
||||||
|
A plugin transition handler is called for every state transition on a
|
||||||
|
``StateTransitionMixin`` model. Subclasses must implement a ``transition``
|
||||||
|
method.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
@@ -37,33 +157,36 @@ class TransitionMethod:
|
|||||||
) -> bool:
|
) -> bool:
|
||||||
"""Perform a state transition.
|
"""Perform a state transition.
|
||||||
|
|
||||||
Success:
|
When used with ``@inventree_transition``-decorated methods, plugin
|
||||||
- The custom transition logic succeeded
|
handlers are invoked before the decorated method body executes.
|
||||||
- Return True result
|
The semantics are:
|
||||||
- No further transitions are attempted
|
|
||||||
Ignore:
|
* **Ignore** - return ``False``. Further handlers are attempted;
|
||||||
- The custom transition logic did not apply
|
the decorated method body executes as normal.
|
||||||
- Return False result
|
* **Veto** - raise ``ValidationError``. The transition is cancelled;
|
||||||
- Further transitions are attempted (if available)
|
no further handlers are called; the method body does *not* execute.
|
||||||
- Default action is called if no transition was successful
|
* **Handle** (deprecated) - return ``True``. This historically meant
|
||||||
Failure:
|
"I handled the transition; skip the default action." Under
|
||||||
- The custom transition logic failed
|
``@inventree_transition``, the decorated method body *always* runs, so
|
||||||
- Raise a ValidationError
|
returning ``True`` now only triggers a ``DeprecationWarning``. Plugin
|
||||||
- No further transitions are attempted
|
authors should raise ``ValidationError`` to cancel a transition, or
|
||||||
- Default action is not called
|
return ``False`` to allow it to proceed.
|
||||||
|
|
||||||
Arguments:
|
Arguments:
|
||||||
current_state: int - Current state of the instance.
|
current_state: int - Current state of the instance.
|
||||||
target_state: int - Target state to transition to.
|
target_state: int - Target state to transition to.
|
||||||
instance: Model - The object instance to transition.
|
instance: Model - The object instance to transition.
|
||||||
default_action: callable - Default action to be taken if no transition is successful.
|
default_action: callable - No-op when called from the
|
||||||
|
``inventree_transition`` wrapper; present for backward
|
||||||
|
compatibility.
|
||||||
**kwargs: Additional keyword arguments for custom logic.
|
**kwargs: Additional keyword arguments for custom logic.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
result: bool - True if the transition method was successful (and no further transitions are attempted), False otherwise.
|
result: bool - ``True`` triggers a deprecation warning (see above).
|
||||||
|
``False`` allows the transition to proceed normally.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ValidationError: Alert the user that the transition failed
|
ValidationError: Cancels the transition.
|
||||||
"""
|
"""
|
||||||
raise NotImplementedError(
|
raise NotImplementedError(
|
||||||
'TransitionMethod.transition must be implemented'
|
'TransitionMethod.transition must be implemented'
|
||||||
@@ -71,63 +194,96 @@ class TransitionMethod:
|
|||||||
|
|
||||||
|
|
||||||
class StateTransitionMixin:
|
class StateTransitionMixin:
|
||||||
"""Mixin class to enable state transitions.
|
"""Mixin class to enable plugin-controlled 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.
|
Add this mixin to a Django model to gain:
|
||||||
```python
|
|
||||||
class MyModel(StateTransitionMixin, models.Model):
|
|
||||||
def some_dummy_function(self, *args, **kwargs):
|
|
||||||
pass
|
|
||||||
|
|
||||||
def action(self, *args, **kwargs):
|
* Plugin hook support for all ``@inventree_transition``-decorated methods
|
||||||
self.handle_transition(0, 1, self, self.some_dummy_function)
|
(via the ``inventree_transition`` decorator).
|
||||||
```
|
* A legacy ``handle_transition`` method for backward compatibility with
|
||||||
|
code that calls it directly.
|
||||||
|
|
||||||
|
Example::
|
||||||
|
|
||||||
|
class MyOrder(StateTransitionMixin, models.Model):
|
||||||
|
status = InvenTreeCustomStatusModelField(
|
||||||
|
status_class=MyOrderStatus, ...
|
||||||
|
)
|
||||||
|
|
||||||
|
@inventree_transition(
|
||||||
|
field=status,
|
||||||
|
source=[MyOrderStatus.PENDING],
|
||||||
|
target=MyOrderStatus.PLACED,
|
||||||
|
)
|
||||||
|
def place_order(self):
|
||||||
|
pass
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
@deprecated('Use the @inventree_transition decorator instead', version='1.5.0')
|
||||||
def handle_transition(
|
def handle_transition(
|
||||||
self, current_state, target_state, instance, default_action, **kwargs
|
self, current_state, target_state, instance, default_action, **kwargs
|
||||||
):
|
): # pragma: no cover
|
||||||
"""Handle a state transition for an object.
|
"""Handle a state transition for an object.
|
||||||
|
|
||||||
|
.. deprecated::
|
||||||
|
Use the ``@inventree_transition`` decorator instead. This method
|
||||||
|
is kept for backward compatibility only and calls the ``default_action``
|
||||||
|
directly without invoking the django-fsm machinery or signals.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
current_state: Current state of instance
|
current_state: Current state of instance
|
||||||
target_state: Target state of instance
|
target_state: Target state of instance
|
||||||
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
|
||||||
"""
|
"""
|
||||||
from InvenTree.exceptions import log_error
|
if result := _run_plugin_transition_handlers(
|
||||||
from plugin import PluginMixinEnum, registry
|
instance, current_state, target_state, default_action=default_action
|
||||||
|
):
|
||||||
transition_plugins = registry.with_mixin(PluginMixinEnum.STATE_TRANSITION)
|
return result
|
||||||
|
|
||||||
for plugin in transition_plugins:
|
|
||||||
try:
|
|
||||||
handlers = plugin.get_transition_handlers()
|
|
||||||
except Exception:
|
|
||||||
log_error('get_transition_handlers', plugin=plugin)
|
|
||||||
continue
|
|
||||||
|
|
||||||
if type(handlers) is not list:
|
|
||||||
logger.error(
|
|
||||||
'INVE-E9: Plugin %s returned invalid type for transition handlers',
|
|
||||||
plugin.slug,
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
for handler in handlers:
|
|
||||||
if not isinstance(handler, TransitionMethod):
|
|
||||||
logger.error(
|
|
||||||
'INVE-E9: 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
|
|
||||||
return default_action(current_state, target_state, instance, **kwargs)
|
return default_action(current_state, target_state, instance, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_plugin_transition_handlers(instance, source, target, default_action):
|
||||||
|
"""Run plugin transition handlers before a state transition.
|
||||||
|
|
||||||
|
A plugin handler may veto the transition by raising a ``ValidationError``.
|
||||||
|
"""
|
||||||
|
if not isinstance(instance, StateTransitionMixin) or not isinstance(
|
||||||
|
instance, Model
|
||||||
|
):
|
||||||
|
return # pragma: no cover
|
||||||
|
|
||||||
|
from InvenTree.exceptions import log_error
|
||||||
|
from plugin import PluginMixinEnum, registry
|
||||||
|
|
||||||
|
transition_plugins = registry.with_mixin(PluginMixinEnum.STATE_TRANSITION)
|
||||||
|
|
||||||
|
for plugin in transition_plugins:
|
||||||
|
try:
|
||||||
|
handlers = plugin.get_transition_handlers()
|
||||||
|
except Exception:
|
||||||
|
log_error('get_transition_handlers', plugin=plugin)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if type(handlers) is not list:
|
||||||
|
logger.error(
|
||||||
|
'INVE-E9: Plugin %s returned invalid type for transition handlers',
|
||||||
|
plugin.slug,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
for handler in handlers:
|
||||||
|
if not isinstance(handler, TransitionMethod):
|
||||||
|
logger.error('INVE-E9: Invalid transition handler type: %s', handler)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Call the transition method.
|
||||||
|
# ValidationError raised here will propagate and cancel the transition.
|
||||||
|
if result := handler.transition(source, target, instance, default_action):
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _noop_default_action(current_state, target_state, instance, **kwargs):
|
||||||
|
"""No-op default action for compatibility with transition handlers."""
|
||||||
|
return None # pragma: no cover
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -711,9 +711,10 @@ class PurchaseOrderTest(OrderTest):
|
|||||||
# completion must be skipped based on the database state
|
# completion must be skipped based on the database state
|
||||||
self.assertEqual(po_b.status, PurchaseOrderStatus.PLACED)
|
self.assertEqual(po_b.status, PurchaseOrderStatus.PLACED)
|
||||||
|
|
||||||
with mock.patch('order.models.trigger_event') as trigger:
|
with self.assertRaises(ValidationError) as err:
|
||||||
po_b.complete_order()
|
po_b.complete_order()
|
||||||
trigger.assert_not_called()
|
|
||||||
|
self.assertIn('Purchase Order is already Complete', str(err.exception))
|
||||||
|
|
||||||
po.refresh_from_db()
|
po.refresh_from_db()
|
||||||
self.assertEqual(po.status, PurchaseOrderStatus.COMPLETE)
|
self.assertEqual(po.status, PurchaseOrderStatus.COMPLETE)
|
||||||
@@ -3546,9 +3547,10 @@ class ReturnOrderTests(InvenTreeAPITestCase):
|
|||||||
# completion must be skipped based on the database state
|
# completion must be skipped based on the database state
|
||||||
self.assertEqual(order_b.status, ReturnOrderStatus.IN_PROGRESS.value)
|
self.assertEqual(order_b.status, ReturnOrderStatus.IN_PROGRESS.value)
|
||||||
|
|
||||||
with mock.patch('order.models.trigger_event') as trigger:
|
with self.assertRaises(ValidationError) as err:
|
||||||
order_b.complete_order()
|
order_b.complete_order()
|
||||||
trigger.assert_not_called()
|
|
||||||
|
self.assertIn('Return Order is already Complete', str(err.exception))
|
||||||
|
|
||||||
rma.refresh_from_db()
|
rma.refresh_from_db()
|
||||||
self.assertEqual(rma.status, ReturnOrderStatus.COMPLETE.value)
|
self.assertEqual(rma.status, ReturnOrderStatus.COMPLETE.value)
|
||||||
@@ -4017,7 +4019,8 @@ class TransferOrderTest(OrderTest):
|
|||||||
self.assertEqual(instance_b.status, TransferOrderStatus.PENDING)
|
self.assertEqual(instance_b.status, TransferOrderStatus.PENDING)
|
||||||
|
|
||||||
with mock.patch('order.models.trigger_event') as trigger:
|
with mock.patch('order.models.trigger_event') as trigger:
|
||||||
instance_b.cancel_order()
|
with self.assertRaises(ValidationError):
|
||||||
|
instance_b.cancel_order()
|
||||||
trigger.assert_not_called()
|
trigger.assert_not_called()
|
||||||
|
|
||||||
to.refresh_from_db()
|
to.refresh_from_db()
|
||||||
@@ -4450,12 +4453,22 @@ class TransferOrderTest(OrderTest):
|
|||||||
with self.assertRaises(ValidationError) as err:
|
with self.assertRaises(ValidationError) as err:
|
||||||
instance_b.complete_order(None)
|
instance_b.complete_order(None)
|
||||||
|
|
||||||
self.assertIn('Order is already complete', str(err.exception))
|
self.assertIn('Transfer Order is already Complete', str(err.exception))
|
||||||
|
|
||||||
# The transferred quantity has not been double-counted
|
# The transferred quantity has not been double-counted
|
||||||
line.refresh_from_db()
|
line.refresh_from_db()
|
||||||
self.assertEqual(line.transferred, 10)
|
self.assertEqual(line.transferred, 10)
|
||||||
|
|
||||||
|
# check that the wrong starting point also triggers an error
|
||||||
|
instance_b.status = TransferOrderStatus.CANCELLED.value
|
||||||
|
instance_b.save()
|
||||||
|
with self.assertRaises(ValidationError) as err:
|
||||||
|
instance_b.complete_order(None)
|
||||||
|
self.assertIn(
|
||||||
|
'Invalid transition on Transfer Order.status (source value should be 20, is 40)',
|
||||||
|
str(err.exception),
|
||||||
|
)
|
||||||
|
|
||||||
def test_output_options(self):
|
def test_output_options(self):
|
||||||
"""Test the output options for the TransferOrder detail endpoint."""
|
"""Test the output options for the TransferOrder detail endpoint."""
|
||||||
self.run_output_test(
|
self.run_output_test(
|
||||||
|
|||||||
@@ -305,8 +305,8 @@ class SalesOrderTest(InvenTreeAPITestCase):
|
|||||||
self.order.can_complete(raise_error=True)
|
self.order.can_complete(raise_error=True)
|
||||||
|
|
||||||
# Now try to ship it - should fail
|
# Now try to ship it - should fail
|
||||||
result = self.order.ship_order(None)
|
with self.assertRaises(ValidationError):
|
||||||
self.assertFalse(result)
|
self.order.ship_order(None)
|
||||||
|
|
||||||
def test_order_cancel_stale_instance_is_noop(self):
|
def test_order_cancel_stale_instance_is_noop(self):
|
||||||
"""A second cancellation attempt with a stale order instance must be a no-op.
|
"""A second cancellation attempt with a stale order instance must be a no-op.
|
||||||
@@ -334,9 +334,8 @@ class SalesOrderTest(InvenTreeAPITestCase):
|
|||||||
# cancellation must be skipped based on the database state
|
# cancellation must be skipped based on the database state
|
||||||
self.assertEqual(order_b.status, status.SalesOrderStatus.PENDING)
|
self.assertEqual(order_b.status, status.SalesOrderStatus.PENDING)
|
||||||
|
|
||||||
with mock.patch('order.models.trigger_event') as trigger:
|
with self.assertRaises(ValidationError):
|
||||||
order_b.cancel_order()
|
order_b.cancel_order()
|
||||||
trigger.assert_not_called()
|
|
||||||
|
|
||||||
self.order.refresh_from_db()
|
self.order.refresh_from_db()
|
||||||
self.assertEqual(self.order.status, status.SalesOrderStatus.CANCELLED)
|
self.assertEqual(self.order.status, status.SalesOrderStatus.CANCELLED)
|
||||||
@@ -353,9 +352,8 @@ class SalesOrderTest(InvenTreeAPITestCase):
|
|||||||
self.assertEqual(SalesOrderAllocation.objects.count(), 2)
|
self.assertEqual(SalesOrderAllocation.objects.count(), 2)
|
||||||
|
|
||||||
# Attempt to ship the order (but shipments are not completed!)
|
# Attempt to ship the order (but shipments are not completed!)
|
||||||
result = self.order.ship_order(None)
|
with self.assertRaises(ValidationError):
|
||||||
|
self.order.ship_order(None)
|
||||||
self.assertFalse(result)
|
|
||||||
|
|
||||||
self.assertIsNone(self.shipment.shipment_date)
|
self.assertIsNone(self.shipment.shipment_date)
|
||||||
self.assertFalse(self.shipment.is_complete())
|
self.assertFalse(self.shipment.is_complete())
|
||||||
@@ -461,9 +459,8 @@ class SalesOrderTest(InvenTreeAPITestCase):
|
|||||||
# completion must be skipped based on the database state
|
# completion must be skipped based on the database state
|
||||||
self.assertEqual(order_b.status, status.SalesOrderStatus.SHIPPED)
|
self.assertEqual(order_b.status, status.SalesOrderStatus.SHIPPED)
|
||||||
|
|
||||||
with mock.patch('order.models.trigger_event') as trigger:
|
with self.assertRaises(ValidationError):
|
||||||
self.assertFalse(order_b.complete_order(None))
|
self.assertFalse(order_b.complete_order(None))
|
||||||
trigger.assert_not_called()
|
|
||||||
|
|
||||||
self.order.refresh_from_db()
|
self.order.refresh_from_db()
|
||||||
self.assertEqual(self.order.status, status.SalesOrderStatus.COMPLETE)
|
self.assertEqual(self.order.status, status.SalesOrderStatus.COMPLETE)
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ class SampleTransitionPlugin(TransitionMixin, InvenTreePlugin):
|
|||||||
msg = 'Return order without responsible owner can not be completed!'
|
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
|
|
||||||
trigger_notification(
|
trigger_notification(
|
||||||
instance,
|
instance,
|
||||||
'sampel_123_456',
|
'sampel_123_456',
|
||||||
@@ -46,7 +46,12 @@ class SampleTransitionPlugin(TransitionMixin, InvenTreePlugin):
|
|||||||
)
|
)
|
||||||
|
|
||||||
raise ValidationError(msg)
|
raise ValidationError(msg)
|
||||||
|
if (
|
||||||
|
instance.responsible
|
||||||
|
and instance.responsible.owner.username == 'testuser'
|
||||||
|
):
|
||||||
|
# If the responsible user is "testuser", we will stop the transition
|
||||||
|
return '123#abc!'
|
||||||
return False # Do not act
|
return False # Do not act
|
||||||
|
|
||||||
TRANSITION_HANDLERS = [ReturnChangeHandler()]
|
TRANSITION_HANDLERS = [ReturnChangeHandler()]
|
||||||
|
|||||||
@@ -510,6 +510,7 @@ django==5.2.16 \
|
|||||||
# django-error-report-2
|
# django-error-report-2
|
||||||
# django-filter
|
# django-filter
|
||||||
# django-flags
|
# django-flags
|
||||||
|
# django-fsm-2
|
||||||
# django-ical
|
# django-ical
|
||||||
# django-js-asset
|
# django-js-asset
|
||||||
# django-maintenance-mode
|
# django-maintenance-mode
|
||||||
@@ -527,6 +528,7 @@ django==5.2.16 \
|
|||||||
# django-stdimage
|
# django-stdimage
|
||||||
# django-storages
|
# django-storages
|
||||||
# django-structlog
|
# django-structlog
|
||||||
|
# django-stubs-ext
|
||||||
# django-taggit
|
# django-taggit
|
||||||
# django-xforwardedfor-middleware
|
# django-xforwardedfor-middleware
|
||||||
# djangorestframework
|
# djangorestframework
|
||||||
@@ -580,6 +582,12 @@ django-flags==5.2.0 \
|
|||||||
# via
|
# via
|
||||||
# -c src/backend/requirements.txt
|
# -c src/backend/requirements.txt
|
||||||
# -r src/backend/requirements.in
|
# -r src/backend/requirements.in
|
||||||
|
django-fsm-2==4.2.4 \
|
||||||
|
--hash=sha256:0072be359520075eee4baead3873e5eb8f1700b4f91e41208058cbea0ceae9b9 \
|
||||||
|
--hash=sha256:0e395afd03ff504afd476e8568ca213aafc3324bd4e5427e5b3287f7b1e7d979
|
||||||
|
# via
|
||||||
|
# -c src/backend/requirements.txt
|
||||||
|
# -r src/backend/requirements.in
|
||||||
django-ical==1.9.2 \
|
django-ical==1.9.2 \
|
||||||
--hash=sha256:44c9b6fa90d09f25e9ebaa91ed9eb007f079afbc23d6aac909cfc18188a8e90c \
|
--hash=sha256:44c9b6fa90d09f25e9ebaa91ed9eb007f079afbc23d6aac909cfc18188a8e90c \
|
||||||
--hash=sha256:74a16bca05735f91a00120cad7250f3c3aa292a9f698a6cfdc544a922c11de70
|
--hash=sha256:74a16bca05735f91a00120cad7250f3c3aa292a9f698a6cfdc544a922c11de70
|
||||||
@@ -703,6 +711,12 @@ django-structlog==10.1.0 \
|
|||||||
# via
|
# via
|
||||||
# -c src/backend/requirements.txt
|
# -c src/backend/requirements.txt
|
||||||
# -r src/backend/requirements.in
|
# -r src/backend/requirements.in
|
||||||
|
django-stubs-ext==6.0.6 \
|
||||||
|
--hash=sha256:5470c970f61a3ccf5aae7633feef1a097f944f417b955da200c9ac26ecd9134b \
|
||||||
|
--hash=sha256:e6f09884e48d7c5b250a373dfa22aa3e83bb91b8babbd8d05187f6b92247f232
|
||||||
|
# via
|
||||||
|
# -c src/backend/requirements.txt
|
||||||
|
# django-fsm-2
|
||||||
django-taggit==6.1.0 \
|
django-taggit==6.1.0 \
|
||||||
--hash=sha256:ab776264bbc76cb3d7e49e1bf9054962457831bd21c3a42db9138b41956e4cf0 \
|
--hash=sha256:ab776264bbc76cb3d7e49e1bf9054962457831bd21c3a42db9138b41956e4cf0 \
|
||||||
--hash=sha256:c4d1199e6df34125dd36db5eb0efe545b254dec3980ce5dd80e6bab3e78757c3
|
--hash=sha256:c4d1199e6df34125dd36db5eb0efe545b254dec3980ce5dd80e6bab3e78757c3
|
||||||
@@ -2127,6 +2141,8 @@ typing-extensions==4.16.0 \
|
|||||||
--hash=sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5
|
--hash=sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5
|
||||||
# via
|
# via
|
||||||
# -c src/backend/requirements.txt
|
# -c src/backend/requirements.txt
|
||||||
|
# -r src/backend/requirements.in
|
||||||
|
# django-stubs-ext
|
||||||
# flexcache
|
# flexcache
|
||||||
# flexparser
|
# flexparser
|
||||||
# grpcio
|
# grpcio
|
||||||
|
|||||||
@@ -414,11 +414,13 @@ django-stubs==6.0.7 \
|
|||||||
# via
|
# via
|
||||||
# -c src/backend/requirements-dev.txt
|
# -c src/backend/requirements-dev.txt
|
||||||
# -r src/backend/requirements-dev.in
|
# -r src/backend/requirements-dev.in
|
||||||
django-stubs-ext==6.0.7 \
|
django-stubs-ext==6.0.6 \
|
||||||
--hash=sha256:53a9c7c5a7c7e718cc6308cfce1e7470f2cac0b9d38dbcd60fbfa82704f1d592 \
|
--hash=sha256:5470c970f61a3ccf5aae7633feef1a097f944f417b955da200c9ac26ecd9134b \
|
||||||
--hash=sha256:c3172c5126614fd2a44d0196b313b44c21f717cb09477ba52b447d41f4ce613e
|
--hash=sha256:e6f09884e48d7c5b250a373dfa22aa3e83bb91b8babbd8d05187f6b92247f232
|
||||||
# via
|
# via
|
||||||
|
# -c src/backend/requirements-3.14.txt
|
||||||
# -c src/backend/requirements-dev.txt
|
# -c src/backend/requirements-dev.txt
|
||||||
|
# -c src/backend/requirements.txt
|
||||||
# django-stubs
|
# django-stubs
|
||||||
django-test-migrations==1.5.0 \
|
django-test-migrations==1.5.0 \
|
||||||
--hash=sha256:1cbff04b1e82c5564a6f635284907b381cc11a2ff883adff46776d9126824f07 \
|
--hash=sha256:1cbff04b1e82c5564a6f635284907b381cc11a2ff883adff46776d9126824f07 \
|
||||||
|
|||||||
@@ -388,10 +388,12 @@ django-stubs==6.0.7 \
|
|||||||
--hash=sha256:7ed9a14c438e589272ca04e966dee82a4d1ff7ca5c2171bc986c50a0d03ec35b \
|
--hash=sha256:7ed9a14c438e589272ca04e966dee82a4d1ff7ca5c2171bc986c50a0d03ec35b \
|
||||||
--hash=sha256:bc55431c0af745a64e39cf33a8d36c87dccbedeae2fe26fab47dd355270e8538
|
--hash=sha256:bc55431c0af745a64e39cf33a8d36c87dccbedeae2fe26fab47dd355270e8538
|
||||||
# via -r src/backend/requirements-dev.in
|
# via -r src/backend/requirements-dev.in
|
||||||
django-stubs-ext==6.0.7 \
|
django-stubs-ext==6.0.6 \
|
||||||
--hash=sha256:53a9c7c5a7c7e718cc6308cfce1e7470f2cac0b9d38dbcd60fbfa82704f1d592 \
|
--hash=sha256:5470c970f61a3ccf5aae7633feef1a097f944f417b955da200c9ac26ecd9134b \
|
||||||
--hash=sha256:c3172c5126614fd2a44d0196b313b44c21f717cb09477ba52b447d41f4ce613e
|
--hash=sha256:e6f09884e48d7c5b250a373dfa22aa3e83bb91b8babbd8d05187f6b92247f232
|
||||||
# via django-stubs
|
# via
|
||||||
|
# -c src/backend/requirements.txt
|
||||||
|
# django-stubs
|
||||||
django-test-migrations==1.5.0 \
|
django-test-migrations==1.5.0 \
|
||||||
--hash=sha256:1cbff04b1e82c5564a6f635284907b381cc11a2ff883adff46776d9126824f07 \
|
--hash=sha256:1cbff04b1e82c5564a6f635284907b381cc11a2ff883adff46776d9126824f07 \
|
||||||
--hash=sha256:96a08f085fc8bfaa53d44618341d82a2d22fd194c821cd81b147b66f0bec0da8
|
--hash=sha256:96a08f085fc8bfaa53d44618341d82a2d22fd194c821cd81b147b66f0bec0da8
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ django-cors-headers # CORS headers extension for DRF
|
|||||||
django-dbbackup # Backup / restore of database and media files
|
django-dbbackup # Backup / restore of database and media files
|
||||||
django-error-report-2 # Error report viewer for the admin interface
|
django-error-report-2 # Error report viewer for the admin interface
|
||||||
django-filter # Extended filtering options
|
django-filter # Extended filtering options
|
||||||
|
django-fsm-2 # Finite state machine for Django models
|
||||||
django-flags # Feature flags
|
django-flags # Feature flags
|
||||||
django-ical # iCal export for calendar views
|
django-ical # iCal export for calendar views
|
||||||
django-maintenance-mode # Shut down application while reloading etc.
|
django-maintenance-mode # Shut down application while reloading etc.
|
||||||
@@ -53,6 +54,7 @@ sentry-sdk # Error reporting (optional)
|
|||||||
setuptools # Standard dependency
|
setuptools # Standard dependency
|
||||||
tablib[xls,xlsx,yaml] # Support for XLS and XLSX formats
|
tablib[xls,xlsx,yaml] # Support for XLS and XLSX formats
|
||||||
tqdm # Progress bars for CLI
|
tqdm # Progress bars for CLI
|
||||||
|
typing_extensions # new features - for 3, 12 support
|
||||||
weasyprint # PDF generation
|
weasyprint # PDF generation
|
||||||
whitenoise # Enhanced static file serving
|
whitenoise # Enhanced static file serving
|
||||||
|
|
||||||
|
|||||||
@@ -486,6 +486,7 @@ django==5.2.16 \
|
|||||||
# django-error-report-2
|
# django-error-report-2
|
||||||
# django-filter
|
# django-filter
|
||||||
# django-flags
|
# django-flags
|
||||||
|
# django-fsm-2
|
||||||
# django-ical
|
# django-ical
|
||||||
# django-js-asset
|
# django-js-asset
|
||||||
# django-maintenance-mode
|
# django-maintenance-mode
|
||||||
@@ -503,6 +504,7 @@ django==5.2.16 \
|
|||||||
# django-stdimage
|
# django-stdimage
|
||||||
# django-storages
|
# django-storages
|
||||||
# django-structlog
|
# django-structlog
|
||||||
|
# django-stubs-ext
|
||||||
# django-taggit
|
# django-taggit
|
||||||
# django-xforwardedfor-middleware
|
# django-xforwardedfor-middleware
|
||||||
# djangorestframework
|
# djangorestframework
|
||||||
@@ -540,6 +542,9 @@ django-flags==5.2.0 \
|
|||||||
--hash=sha256:a3aee5e0d11e24e35f5ae4a423d52cad721f639181c1b1251e0148d8530a3f81 \
|
--hash=sha256:a3aee5e0d11e24e35f5ae4a423d52cad721f639181c1b1251e0148d8530a3f81 \
|
||||||
--hash=sha256:aa22543410f68a4082c576f3be469ea67a51c933f8a43eb8b10f6236f158e17e
|
--hash=sha256:aa22543410f68a4082c576f3be469ea67a51c933f8a43eb8b10f6236f158e17e
|
||||||
# via -r src/backend/requirements.in
|
# via -r src/backend/requirements.in
|
||||||
|
django-fsm-2==4.2.4 \
|
||||||
|
--hash=sha256:0072be359520075eee4baead3873e5eb8f1700b4f91e41208058cbea0ceae9b9
|
||||||
|
# via -r src/backend/requirements.in
|
||||||
django-ical==1.9.2 \
|
django-ical==1.9.2 \
|
||||||
--hash=sha256:44c9b6fa90d09f25e9ebaa91ed9eb007f079afbc23d6aac909cfc18188a8e90c \
|
--hash=sha256:44c9b6fa90d09f25e9ebaa91ed9eb007f079afbc23d6aac909cfc18188a8e90c \
|
||||||
--hash=sha256:74a16bca05735f91a00120cad7250f3c3aa292a9f698a6cfdc544a922c11de70
|
--hash=sha256:74a16bca05735f91a00120cad7250f3c3aa292a9f698a6cfdc544a922c11de70
|
||||||
@@ -621,6 +626,10 @@ django-structlog==10.1.0 \
|
|||||||
--hash=sha256:450842bfb03887fc14469570769e515d22ea67d753848a25cdbd05405382c090 \
|
--hash=sha256:450842bfb03887fc14469570769e515d22ea67d753848a25cdbd05405382c090 \
|
||||||
--hash=sha256:812f61dea873e559511f42c0f3a7cdc36d4472f6cc15460565a97e5cbe06a76d
|
--hash=sha256:812f61dea873e559511f42c0f3a7cdc36d4472f6cc15460565a97e5cbe06a76d
|
||||||
# via -r src/backend/requirements.in
|
# via -r src/backend/requirements.in
|
||||||
|
django-stubs-ext==6.0.6 \
|
||||||
|
--hash=sha256:5470c970f61a3ccf5aae7633feef1a097f944f417b955da200c9ac26ecd9134b \
|
||||||
|
--hash=sha256:e6f09884e48d7c5b250a373dfa22aa3e83bb91b8babbd8d05187f6b92247f232
|
||||||
|
# via django-fsm-2
|
||||||
django-taggit==6.1.0 \
|
django-taggit==6.1.0 \
|
||||||
--hash=sha256:ab776264bbc76cb3d7e49e1bf9054962457831bd21c3a42db9138b41956e4cf0 \
|
--hash=sha256:ab776264bbc76cb3d7e49e1bf9054962457831bd21c3a42db9138b41956e4cf0 \
|
||||||
--hash=sha256:c4d1199e6df34125dd36db5eb0efe545b254dec3980ce5dd80e6bab3e78757c3
|
--hash=sha256:c4d1199e6df34125dd36db5eb0efe545b254dec3980ce5dd80e6bab3e78757c3
|
||||||
@@ -1883,7 +1892,9 @@ typing-extensions==4.16.0 \
|
|||||||
--hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 \
|
--hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 \
|
||||||
--hash=sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5
|
--hash=sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5
|
||||||
# via
|
# via
|
||||||
|
# -r src/backend/requirements.in
|
||||||
# django-redis
|
# django-redis
|
||||||
|
# django-stubs-ext
|
||||||
# flexcache
|
# flexcache
|
||||||
# flexparser
|
# flexparser
|
||||||
# grpcio
|
# grpcio
|
||||||
|
|||||||
Reference in New Issue
Block a user