mirror of
https://github.com/inventree/InvenTree.git
synced 2025-05-06 23:38:48 +00:00
[Refactor] Custom states (#8438)
* Enhancements for "custom state" form - More intuitive form actions * Improve back-end validation * Improve table rendering * Fix lookup for useStatusCodes * Fix status display for SockDetail page * Fix SalesOrder status display * Refactor get_custom_classes - Add StatusCode.custom_values method * Fix for status table filters * Cleanup (and note to self) * Include custom state values in specific API endpoints * Add serializer class definition * Use same serializer for AllStatusView * Fix API to match existing frontend type StatusCodeListInterface * Enable filtering by reference status type * Add option to duplicate an existing custom state * Improved validation for the InvenTreeCustomUserStateModel class * Code cleanup * Fix default value in StockOperationsRow * Use custom status values in stock operations * Allow custom values * Fix migration * Bump API version * Fix filtering of stock items by "status" * Enhance status filter for orders * Fix status code rendering * Build Order API filter * Update playwright tests for build filters * Additional playwright tests for stock table filters * Add 'custom' attribute * Fix unit tests * Add custom state field validation * Implement StatusCodeMixin for setting status code values * Clear out 'custom key' if the base key does not match * Updated playwright testing * Remove timeout * Refactor detail pages which display status * Update old migrations - add field validator * Remove dead code * Simplify API query filtering * Revert "Simplify API query filtering" This reverts commit 06c858ae7ce1feab5af0f91993b42ba8a81e588a. * Fix save method * Unit test fixes * Fix for ReturnOrderLineItem * Reorganize code * Adjust unit test
This commit is contained in:
parent
c582ca0afd
commit
964984ccac
@ -1,13 +1,16 @@
|
|||||||
"""InvenTree API version information."""
|
"""InvenTree API version information."""
|
||||||
|
|
||||||
# InvenTree API version
|
# InvenTree API version
|
||||||
INVENTREE_API_VERSION = 296
|
INVENTREE_API_VERSION = 297
|
||||||
|
|
||||||
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||||
|
|
||||||
|
|
||||||
INVENTREE_API_TEXT = """
|
INVENTREE_API_TEXT = """
|
||||||
|
|
||||||
|
v297 - 2024-12-29 - https://github.com/inventree/InvenTree/pull/8438
|
||||||
|
- Adjustments to the CustomUserState API endpoints and serializers
|
||||||
|
|
||||||
v296 - 2024-12-25 : https://github.com/inventree/InvenTree/pull/8732
|
v296 - 2024-12-25 : https://github.com/inventree/InvenTree/pull/8732
|
||||||
- Adjust default "part_detail" behaviour for StockItem API endpoints
|
- Adjust default "part_detail" behaviour for StockItem API endpoints
|
||||||
|
|
||||||
|
@ -34,7 +34,17 @@ class BuildFilter(rest_filters.FilterSet):
|
|||||||
model = Build
|
model = Build
|
||||||
fields = ['sales_order']
|
fields = ['sales_order']
|
||||||
|
|
||||||
status = rest_filters.NumberFilter(label='Status')
|
status = rest_filters.NumberFilter(label=_('Order Status'), method='filter_status')
|
||||||
|
|
||||||
|
def filter_status(self, queryset, name, value):
|
||||||
|
"""Filter by integer status code.
|
||||||
|
|
||||||
|
Note: Also account for the possibility of a custom status code
|
||||||
|
"""
|
||||||
|
q1 = Q(status=value, status_custom_key__isnull=True)
|
||||||
|
q2 = Q(status_custom_key=value)
|
||||||
|
|
||||||
|
return queryset.filter(q1 | q2).distinct()
|
||||||
|
|
||||||
active = rest_filters.BooleanFilter(label='Build is active', method='filter_active')
|
active = rest_filters.BooleanFilter(label='Build is active', method='filter_active')
|
||||||
|
|
||||||
|
@ -4,6 +4,7 @@ import django.core.validators
|
|||||||
from django.db import migrations
|
from django.db import migrations
|
||||||
|
|
||||||
import generic.states.fields
|
import generic.states.fields
|
||||||
|
import generic.states.validators
|
||||||
import InvenTree.status_codes
|
import InvenTree.status_codes
|
||||||
|
|
||||||
|
|
||||||
@ -23,6 +24,11 @@ class Migration(migrations.Migration):
|
|||||||
help_text="Additional status information for this item",
|
help_text="Additional status information for this item",
|
||||||
null=True,
|
null=True,
|
||||||
verbose_name="Custom status key",
|
verbose_name="Custom status key",
|
||||||
|
validators=[
|
||||||
|
generic.states.validators.CustomStatusCodeValidator(
|
||||||
|
status_class=InvenTree.status_codes.BuildStatus
|
||||||
|
),
|
||||||
|
]
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
@ -32,7 +38,12 @@ class Migration(migrations.Migration):
|
|||||||
choices=InvenTree.status_codes.BuildStatus.items(),
|
choices=InvenTree.status_codes.BuildStatus.items(),
|
||||||
default=10,
|
default=10,
|
||||||
help_text="Build status code",
|
help_text="Build status code",
|
||||||
validators=[django.core.validators.MinValueValidator(0)],
|
validators=[
|
||||||
|
django.core.validators.MinValueValidator(0),
|
||||||
|
generic.states.validators.CustomStatusCodeValidator(
|
||||||
|
status_class=InvenTree.status_codes.BuildStatus
|
||||||
|
),
|
||||||
|
],
|
||||||
verbose_name="Build Status",
|
verbose_name="Build Status",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
@ -43,7 +43,7 @@ from common.settings import (
|
|||||||
get_global_setting,
|
get_global_setting,
|
||||||
prevent_build_output_complete_on_incompleted_tests,
|
prevent_build_output_complete_on_incompleted_tests,
|
||||||
)
|
)
|
||||||
from generic.states import StateTransitionMixin
|
from generic.states import StateTransitionMixin, StatusCodeMixin
|
||||||
from plugin.events import trigger_event
|
from plugin.events import trigger_event
|
||||||
from stock.status_codes import StockHistoryCode, StockStatus
|
from stock.status_codes import StockHistoryCode, StockStatus
|
||||||
|
|
||||||
@ -59,6 +59,7 @@ class Build(
|
|||||||
InvenTree.models.PluginValidationMixin,
|
InvenTree.models.PluginValidationMixin,
|
||||||
InvenTree.models.ReferenceIndexingMixin,
|
InvenTree.models.ReferenceIndexingMixin,
|
||||||
StateTransitionMixin,
|
StateTransitionMixin,
|
||||||
|
StatusCodeMixin,
|
||||||
MPTTModel,
|
MPTTModel,
|
||||||
):
|
):
|
||||||
"""A Build object organises the creation of new StockItem objects from other existing StockItem objects.
|
"""A Build object organises the creation of new StockItem objects from other existing StockItem objects.
|
||||||
@ -84,6 +85,8 @@ class Build(
|
|||||||
priority: Priority of the build
|
priority: Priority of the build
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
STATUS_CLASS = BuildStatus
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
"""Metaclass options for the BuildOrder model."""
|
"""Metaclass options for the BuildOrder model."""
|
||||||
|
|
||||||
@ -319,6 +322,7 @@ class Build(
|
|||||||
verbose_name=_('Build Status'),
|
verbose_name=_('Build Status'),
|
||||||
default=BuildStatus.PENDING.value,
|
default=BuildStatus.PENDING.value,
|
||||||
choices=BuildStatus.items(),
|
choices=BuildStatus.items(),
|
||||||
|
status_class=BuildStatus,
|
||||||
validators=[MinValueValidator(0)],
|
validators=[MinValueValidator(0)],
|
||||||
help_text=_('Build status code'),
|
help_text=_('Build status code'),
|
||||||
)
|
)
|
||||||
|
@ -574,7 +574,9 @@ class BuildOutputCompleteSerializer(serializers.Serializer):
|
|||||||
)
|
)
|
||||||
|
|
||||||
status_custom_key = serializers.ChoiceField(
|
status_custom_key = serializers.ChoiceField(
|
||||||
choices=StockStatus.items(), default=StockStatus.OK.value, label=_('Status')
|
choices=StockStatus.items(custom=True),
|
||||||
|
default=StockStatus.OK.value,
|
||||||
|
label=_('Status'),
|
||||||
)
|
)
|
||||||
|
|
||||||
accept_incomplete_allocation = serializers.BooleanField(
|
accept_incomplete_allocation = serializers.BooleanField(
|
||||||
|
@ -0,0 +1,32 @@
|
|||||||
|
# Generated by Django 4.2.17 on 2024-12-27 09:15
|
||||||
|
|
||||||
|
import common.validators
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('common', '0033_delete_colortheme'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterUniqueTogether(
|
||||||
|
name='inventreecustomuserstatemodel',
|
||||||
|
unique_together=set(),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='inventreecustomuserstatemodel',
|
||||||
|
name='key',
|
||||||
|
field=models.IntegerField(help_text='Numerical value that will be saved in the models database', verbose_name='Value'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='inventreecustomuserstatemodel',
|
||||||
|
name='name',
|
||||||
|
field=models.CharField(help_text='Name of the state', max_length=250, validators=[common.validators.validate_uppercase, common.validators.validate_variable_string], verbose_name='Name'),
|
||||||
|
),
|
||||||
|
migrations.AlterUniqueTogether(
|
||||||
|
name='inventreecustomuserstatemodel',
|
||||||
|
unique_together={('reference_status', 'name'), ('reference_status', 'key')},
|
||||||
|
),
|
||||||
|
]
|
@ -51,7 +51,7 @@ import InvenTree.validators
|
|||||||
import users.models
|
import users.models
|
||||||
from common.setting.type import InvenTreeSettingsKeyType, SettingsKeyType
|
from common.setting.type import InvenTreeSettingsKeyType, SettingsKeyType
|
||||||
from generic.states import ColorEnum
|
from generic.states import ColorEnum
|
||||||
from generic.states.custom import get_custom_classes, state_color_mappings
|
from generic.states.custom import state_color_mappings
|
||||||
from InvenTree.sanitizer import sanitize_svg
|
from InvenTree.sanitizer import sanitize_svg
|
||||||
|
|
||||||
logger = logging.getLogger('inventree')
|
logger = logging.getLogger('inventree')
|
||||||
@ -1927,20 +1927,59 @@ class Attachment(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel
|
|||||||
|
|
||||||
|
|
||||||
class InvenTreeCustomUserStateModel(models.Model):
|
class InvenTreeCustomUserStateModel(models.Model):
|
||||||
"""Custom model to extends any registered state with extra custom, user defined states."""
|
"""Custom model to extends any registered state with extra custom, user defined states.
|
||||||
|
|
||||||
|
Fields:
|
||||||
|
reference_status: Status set that is extended with this custom state
|
||||||
|
logical_key: State logical key that is equal to this custom state in business logic
|
||||||
|
key: Numerical value that will be saved in the models database
|
||||||
|
name: Name of the state (must be uppercase and a valid variable identifier)
|
||||||
|
label: Label that will be displayed in the frontend (human readable)
|
||||||
|
color: Color that will be displayed in the frontend
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
"""Metaclass options for this mixin."""
|
||||||
|
|
||||||
|
verbose_name = _('Custom State')
|
||||||
|
verbose_name_plural = _('Custom States')
|
||||||
|
unique_together = [('reference_status', 'key'), ('reference_status', 'name')]
|
||||||
|
|
||||||
|
reference_status = models.CharField(
|
||||||
|
max_length=250,
|
||||||
|
verbose_name=_('Reference Status Set'),
|
||||||
|
help_text=_('Status set that is extended with this custom state'),
|
||||||
|
)
|
||||||
|
|
||||||
|
logical_key = models.IntegerField(
|
||||||
|
verbose_name=_('Logical Key'),
|
||||||
|
help_text=_(
|
||||||
|
'State logical key that is equal to this custom state in business logic'
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
key = models.IntegerField(
|
key = models.IntegerField(
|
||||||
verbose_name=_('Key'),
|
verbose_name=_('Value'),
|
||||||
help_text=_('Value that will be saved in the models database'),
|
help_text=_('Numerical value that will be saved in the models database'),
|
||||||
)
|
)
|
||||||
|
|
||||||
name = models.CharField(
|
name = models.CharField(
|
||||||
max_length=250, verbose_name=_('Name'), help_text=_('Name of the state')
|
max_length=250,
|
||||||
|
verbose_name=_('Name'),
|
||||||
|
help_text=_('Name of the state'),
|
||||||
|
validators=[
|
||||||
|
common.validators.validate_uppercase,
|
||||||
|
common.validators.validate_variable_string,
|
||||||
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
label = models.CharField(
|
label = models.CharField(
|
||||||
max_length=250,
|
max_length=250,
|
||||||
verbose_name=_('Label'),
|
verbose_name=_('Label'),
|
||||||
help_text=_('Label that will be displayed in the frontend'),
|
help_text=_('Label that will be displayed in the frontend'),
|
||||||
)
|
)
|
||||||
|
|
||||||
color = models.CharField(
|
color = models.CharField(
|
||||||
max_length=10,
|
max_length=10,
|
||||||
choices=state_color_mappings(),
|
choices=state_color_mappings(),
|
||||||
@ -1948,12 +1987,7 @@ class InvenTreeCustomUserStateModel(models.Model):
|
|||||||
verbose_name=_('Color'),
|
verbose_name=_('Color'),
|
||||||
help_text=_('Color that will be displayed in the frontend'),
|
help_text=_('Color that will be displayed in the frontend'),
|
||||||
)
|
)
|
||||||
logical_key = models.IntegerField(
|
|
||||||
verbose_name=_('Logical Key'),
|
|
||||||
help_text=_(
|
|
||||||
'State logical key that is equal to this custom state in business logic'
|
|
||||||
),
|
|
||||||
)
|
|
||||||
model = models.ForeignKey(
|
model = models.ForeignKey(
|
||||||
ContentType,
|
ContentType,
|
||||||
on_delete=models.SET_NULL,
|
on_delete=models.SET_NULL,
|
||||||
@ -1962,18 +1996,6 @@ class InvenTreeCustomUserStateModel(models.Model):
|
|||||||
verbose_name=_('Model'),
|
verbose_name=_('Model'),
|
||||||
help_text=_('Model this state is associated with'),
|
help_text=_('Model this state is associated with'),
|
||||||
)
|
)
|
||||||
reference_status = models.CharField(
|
|
||||||
max_length=250,
|
|
||||||
verbose_name=_('Reference Status Set'),
|
|
||||||
help_text=_('Status set that is extended with this custom state'),
|
|
||||||
)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
"""Metaclass options for this mixin."""
|
|
||||||
|
|
||||||
verbose_name = _('Custom State')
|
|
||||||
verbose_name_plural = _('Custom States')
|
|
||||||
unique_together = [['model', 'reference_status', 'key', 'logical_key']]
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
"""Return string representation of the custom state."""
|
"""Return string representation of the custom state."""
|
||||||
@ -1999,38 +2021,50 @@ class InvenTreeCustomUserStateModel(models.Model):
|
|||||||
if self.key == self.logical_key:
|
if self.key == self.logical_key:
|
||||||
raise ValidationError({'key': _('Key must be different from logical key')})
|
raise ValidationError({'key': _('Key must be different from logical key')})
|
||||||
|
|
||||||
if self.reference_status is None or self.reference_status == '':
|
# Check against the reference status class
|
||||||
|
status_class = self.get_status_class()
|
||||||
|
|
||||||
|
if not status_class:
|
||||||
raise ValidationError({
|
raise ValidationError({
|
||||||
'reference_status': _('Reference status must be selected')
|
'reference_status': _('Valid reference status class must be provided')
|
||||||
})
|
})
|
||||||
|
|
||||||
# Ensure that the key is not in the range of the logical keys of the reference status
|
if self.key in status_class.values():
|
||||||
ref_set = list(
|
|
||||||
filter(
|
|
||||||
lambda x: x.__name__ == self.reference_status,
|
|
||||||
get_custom_classes(include_custom=False),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if len(ref_set) == 0:
|
|
||||||
raise ValidationError({
|
|
||||||
'reference_status': _('Reference status set not found')
|
|
||||||
})
|
|
||||||
ref_set = ref_set[0]
|
|
||||||
if self.key in ref_set.keys(): # noqa: SIM118
|
|
||||||
raise ValidationError({
|
raise ValidationError({
|
||||||
'key': _(
|
'key': _(
|
||||||
'Key must be different from the logical keys of the reference status'
|
'Key must be different from the logical keys of the reference status'
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
if self.logical_key not in ref_set.keys(): # noqa: SIM118
|
|
||||||
|
if self.logical_key not in status_class.values():
|
||||||
raise ValidationError({
|
raise ValidationError({
|
||||||
'logical_key': _(
|
'logical_key': _(
|
||||||
'Logical key must be in the logical keys of the reference status'
|
'Logical key must be in the logical keys of the reference status'
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if self.name in status_class.names():
|
||||||
|
raise ValidationError({
|
||||||
|
'name': _(
|
||||||
|
'Name must be different from the names of the reference status'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
return super().clean()
|
return super().clean()
|
||||||
|
|
||||||
|
def get_status_class(self):
|
||||||
|
"""Return the appropriate status class for this custom state."""
|
||||||
|
from generic.states import StatusCode
|
||||||
|
from InvenTree.helpers import inheritors
|
||||||
|
|
||||||
|
if not self.reference_status:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Return the first class that matches the reference status
|
||||||
|
for cls in inheritors(StatusCode):
|
||||||
|
if cls.__name__ == self.reference_status:
|
||||||
|
return cls
|
||||||
|
|
||||||
|
|
||||||
class SelectionList(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel):
|
class SelectionList(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel):
|
||||||
"""Class which represents a list of selectable items for parameters.
|
"""Class which represents a list of selectable items for parameters.
|
||||||
|
@ -355,6 +355,7 @@ class CustomStateSerializer(DataImportExportSerializerMixin, InvenTreeModelSeria
|
|||||||
]
|
]
|
||||||
|
|
||||||
model_name = serializers.CharField(read_only=True, source='model.name')
|
model_name = serializers.CharField(read_only=True, source='model.name')
|
||||||
|
|
||||||
reference_status = serializers.ChoiceField(
|
reference_status = serializers.ChoiceField(
|
||||||
choices=generic.states.custom.state_reference_mappings()
|
choices=generic.states.custom.state_reference_mappings()
|
||||||
)
|
)
|
||||||
|
@ -113,3 +113,17 @@ def validate_icon(name: Union[str, None]):
|
|||||||
return
|
return
|
||||||
|
|
||||||
common.icons.validate_icon(name)
|
common.icons.validate_icon(name)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_uppercase(value: str):
|
||||||
|
"""Ensure that the provided value is uppercase."""
|
||||||
|
value = str(value)
|
||||||
|
|
||||||
|
if value != value.upper():
|
||||||
|
raise ValidationError(_('Value must be uppercase'))
|
||||||
|
|
||||||
|
|
||||||
|
def validate_variable_string(value: str):
|
||||||
|
"""The passed value must be a valid variable identifier string."""
|
||||||
|
if not re.match(r'^[a-zA-Z_][a-zA-Z0-9_]*$', value):
|
||||||
|
raise ValidationError(_('Value must be a valid variable identifier'))
|
||||||
|
@ -6,13 +6,14 @@ 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 .states import ColorEnum, StatusCode
|
from .states import ColorEnum, StatusCode, StatusCodeMixin
|
||||||
from .transition import StateTransitionMixin, TransitionMethod, storage
|
from .transition import StateTransitionMixin, TransitionMethod, storage
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'ColorEnum',
|
'ColorEnum',
|
||||||
'StateTransitionMixin',
|
'StateTransitionMixin',
|
||||||
'StatusCode',
|
'StatusCode',
|
||||||
|
'StatusCodeMixin',
|
||||||
'TransitionMethod',
|
'TransitionMethod',
|
||||||
'storage',
|
'storage',
|
||||||
]
|
]
|
||||||
|
@ -11,14 +11,13 @@ from rest_framework.response import Response
|
|||||||
|
|
||||||
import common.models
|
import common.models
|
||||||
import common.serializers
|
import common.serializers
|
||||||
from generic.states.custom import get_status_api_response
|
|
||||||
from importer.mixins import DataExportViewMixin
|
from importer.mixins import DataExportViewMixin
|
||||||
from InvenTree.filters import SEARCH_ORDER_FILTER
|
from InvenTree.filters import SEARCH_ORDER_FILTER
|
||||||
from InvenTree.mixins import ListCreateAPI, RetrieveUpdateDestroyAPI
|
from InvenTree.mixins import ListCreateAPI, RetrieveUpdateDestroyAPI
|
||||||
from InvenTree.permissions import IsStaffOrReadOnly
|
from InvenTree.permissions import IsStaffOrReadOnly
|
||||||
from InvenTree.serializers import EmptySerializer
|
from InvenTree.serializers import EmptySerializer
|
||||||
from machine.machine_type import MachineStatus
|
|
||||||
|
|
||||||
|
from .serializers import GenericStateClassSerializer
|
||||||
from .states import StatusCode
|
from .states import StatusCode
|
||||||
|
|
||||||
|
|
||||||
@ -38,6 +37,7 @@ class StatusView(GenericAPIView):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
permission_classes = [permissions.IsAuthenticated]
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
serializer_class = GenericStateClassSerializer
|
||||||
|
|
||||||
# Override status_class for implementing subclass
|
# Override status_class for implementing subclass
|
||||||
MODEL_REF = 'statusmodel'
|
MODEL_REF = 'statusmodel'
|
||||||
@ -56,7 +56,7 @@ class StatusView(GenericAPIView):
|
|||||||
@extend_schema(
|
@extend_schema(
|
||||||
description='Retrieve information about a specific status code',
|
description='Retrieve information about a specific status code',
|
||||||
responses={
|
responses={
|
||||||
200: OpenApiResponse(description='Status code information'),
|
200: GenericStateClassSerializer,
|
||||||
400: OpenApiResponse(description='Invalid request'),
|
400: OpenApiResponse(description='Invalid request'),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@ -70,9 +70,27 @@ class StatusView(GenericAPIView):
|
|||||||
if not issubclass(status_class, StatusCode):
|
if not issubclass(status_class, StatusCode):
|
||||||
raise NotImplementedError('`status_class` not a valid StatusCode class')
|
raise NotImplementedError('`status_class` not a valid StatusCode class')
|
||||||
|
|
||||||
data = {'class': status_class.__name__, 'values': status_class.dict()}
|
data = {'status_class': status_class.__name__, 'values': status_class.dict()}
|
||||||
|
|
||||||
return Response(data)
|
# Extend with custom values
|
||||||
|
try:
|
||||||
|
custom_values = status_class.custom_values()
|
||||||
|
for item in custom_values:
|
||||||
|
if item.name not in data['values']:
|
||||||
|
data['values'][item.name] = {
|
||||||
|
'color': item.color,
|
||||||
|
'logical_key': item.logical_key,
|
||||||
|
'key': item.key,
|
||||||
|
'label': item.label,
|
||||||
|
'name': item.name,
|
||||||
|
'custom': True,
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
serializer = GenericStateClassSerializer(data, many=False)
|
||||||
|
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
|
||||||
class AllStatusViews(StatusView):
|
class AllStatusViews(StatusView):
|
||||||
@ -83,9 +101,32 @@ class AllStatusViews(StatusView):
|
|||||||
|
|
||||||
def get(self, request, *args, **kwargs):
|
def get(self, request, *args, **kwargs):
|
||||||
"""Perform a GET request to learn information about status codes."""
|
"""Perform a GET request to learn information about status codes."""
|
||||||
data = get_status_api_response()
|
from InvenTree.helpers import inheritors
|
||||||
# Extend with MachineStatus classes
|
|
||||||
data.update(get_status_api_response(MachineStatus, prefix=['MachineStatus']))
|
data = {}
|
||||||
|
|
||||||
|
# Find all inherited status classes
|
||||||
|
status_classes = inheritors(StatusCode)
|
||||||
|
|
||||||
|
for cls in status_classes:
|
||||||
|
cls_data = {'status_class': cls.__name__, 'values': cls.dict()}
|
||||||
|
|
||||||
|
# Extend with custom values
|
||||||
|
for item in cls.custom_values():
|
||||||
|
label = str(item.name)
|
||||||
|
if label not in cls_data['values']:
|
||||||
|
print('custom value:', item)
|
||||||
|
cls_data['values'][label] = {
|
||||||
|
'color': item.color,
|
||||||
|
'logical_key': item.logical_key,
|
||||||
|
'key': item.key,
|
||||||
|
'label': item.label,
|
||||||
|
'name': item.name,
|
||||||
|
'custom': True,
|
||||||
|
}
|
||||||
|
|
||||||
|
data[cls.__name__] = GenericStateClassSerializer(cls_data, many=False).data
|
||||||
|
|
||||||
return Response(data)
|
return Response(data)
|
||||||
|
|
||||||
|
|
||||||
@ -99,6 +140,7 @@ class CustomStateList(DataExportViewMixin, ListCreateAPI):
|
|||||||
filter_backends = SEARCH_ORDER_FILTER
|
filter_backends = SEARCH_ORDER_FILTER
|
||||||
ordering_fields = ['key']
|
ordering_fields = ['key']
|
||||||
search_fields = ['key', 'name', 'label', 'reference_status']
|
search_fields = ['key', 'name', 'label', 'reference_status']
|
||||||
|
filterset_fields = ['model', 'reference_status']
|
||||||
|
|
||||||
|
|
||||||
class CustomStateDetail(RetrieveUpdateDestroyAPI):
|
class CustomStateDetail(RetrieveUpdateDestroyAPI):
|
||||||
|
@ -7,23 +7,7 @@ from .states import ColorEnum, StatusCode
|
|||||||
|
|
||||||
def get_custom_status_labels(include_custom: bool = True):
|
def get_custom_status_labels(include_custom: bool = True):
|
||||||
"""Return a dict of custom status labels."""
|
"""Return a dict of custom status labels."""
|
||||||
return {cls.tag(): cls for cls in get_custom_classes(include_custom)}
|
return {cls.tag(): cls for cls in inheritors(StatusCode)}
|
||||||
|
|
||||||
|
|
||||||
def get_status_api_response(base_class=StatusCode, prefix=None):
|
|
||||||
"""Return a dict of status classes (custom and class defined).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
base_class: The base class to search for subclasses.
|
|
||||||
prefix: A list of strings to prefix the class names with.
|
|
||||||
"""
|
|
||||||
return {
|
|
||||||
'__'.join([*(prefix or []), k.__name__]): {
|
|
||||||
'class': k.__name__,
|
|
||||||
'values': k.dict(),
|
|
||||||
}
|
|
||||||
for k in get_custom_classes(base_class=base_class, subclass=False)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def state_color_mappings():
|
def state_color_mappings():
|
||||||
@ -33,7 +17,7 @@ def state_color_mappings():
|
|||||||
|
|
||||||
def state_reference_mappings():
|
def state_reference_mappings():
|
||||||
"""Return a list of custom user state references."""
|
"""Return a list of custom user state references."""
|
||||||
classes = get_custom_classes(include_custom=False)
|
classes = inheritors(StatusCode)
|
||||||
return [(a.__name__, a.__name__) for a in sorted(classes, key=lambda x: x.__name__)]
|
return [(a.__name__, a.__name__) for a in sorted(classes, key=lambda x: x.__name__)]
|
||||||
|
|
||||||
|
|
||||||
@ -42,48 +26,3 @@ def get_logical_value(value, model: str):
|
|||||||
from common.models import InvenTreeCustomUserStateModel
|
from common.models import InvenTreeCustomUserStateModel
|
||||||
|
|
||||||
return InvenTreeCustomUserStateModel.objects.get(key=value, model__model=model)
|
return InvenTreeCustomUserStateModel.objects.get(key=value, model__model=model)
|
||||||
|
|
||||||
|
|
||||||
def get_custom_classes(
|
|
||||||
include_custom: bool = True, base_class=StatusCode, subclass=False
|
|
||||||
):
|
|
||||||
"""Return a dict of status classes (custom and class defined)."""
|
|
||||||
discovered_classes = inheritors(base_class, subclass)
|
|
||||||
|
|
||||||
if not include_custom:
|
|
||||||
return discovered_classes
|
|
||||||
|
|
||||||
# Gather DB settings
|
|
||||||
from common.models import InvenTreeCustomUserStateModel
|
|
||||||
|
|
||||||
custom_db_states = {}
|
|
||||||
custom_db_mdls = {}
|
|
||||||
for item in list(InvenTreeCustomUserStateModel.objects.all()):
|
|
||||||
if not custom_db_states.get(item.reference_status):
|
|
||||||
custom_db_states[item.reference_status] = []
|
|
||||||
custom_db_states[item.reference_status].append(item)
|
|
||||||
custom_db_mdls[item.model.app_label] = item.reference_status
|
|
||||||
custom_db_mdls_keys = custom_db_mdls.keys()
|
|
||||||
|
|
||||||
states = {}
|
|
||||||
for cls in discovered_classes:
|
|
||||||
tag = cls.tag()
|
|
||||||
states[tag] = cls
|
|
||||||
if custom_db_mdls and tag in custom_db_mdls_keys:
|
|
||||||
data = [(str(m.name), (m.value, m.label, m.color)) for m in states[tag]]
|
|
||||||
data_keys = [i[0] for i in data]
|
|
||||||
|
|
||||||
# Extent with non present tags
|
|
||||||
for entry in custom_db_states[custom_db_mdls[tag]]:
|
|
||||||
ref_name = str(entry.name.upper().replace(' ', ''))
|
|
||||||
if ref_name not in data_keys:
|
|
||||||
data += [
|
|
||||||
(
|
|
||||||
str(entry.name.upper().replace(' ', '')),
|
|
||||||
(entry.key, entry.label, entry.color),
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
# Re-assemble the enum
|
|
||||||
states[tag] = base_class(f'{tag.capitalize()}Status', data)
|
|
||||||
return states.values()
|
|
||||||
|
@ -90,6 +90,20 @@ class InvenTreeCustomStatusModelField(models.PositiveIntegerField):
|
|||||||
Models using this model field must also include the InvenTreeCustomStatusSerializerMixin in all serializers that create or update the value.
|
Models using this model field must also include the InvenTreeCustomStatusSerializerMixin in all serializers that create or update the value.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
"""Initialize the field."""
|
||||||
|
from generic.states.validators import CustomStatusCodeValidator
|
||||||
|
|
||||||
|
self.status_class = kwargs.pop('status_class', None)
|
||||||
|
|
||||||
|
validators = kwargs.pop('validators', None) or []
|
||||||
|
|
||||||
|
if self.status_class:
|
||||||
|
validators.append(CustomStatusCodeValidator(status_class=self.status_class))
|
||||||
|
|
||||||
|
kwargs['validators'] = validators
|
||||||
|
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()
|
name, path, args, kwargs = super().deconstruct()
|
||||||
@ -109,14 +123,23 @@ class InvenTreeCustomStatusModelField(models.PositiveIntegerField):
|
|||||||
"""Ensure that the value is not an empty string."""
|
"""Ensure that the value is not an empty string."""
|
||||||
if value == '':
|
if value == '':
|
||||||
value = None
|
value = None
|
||||||
|
|
||||||
return super().clean(value, model_instance)
|
return super().clean(value, model_instance)
|
||||||
|
|
||||||
def add_field(self, cls, name):
|
def add_field(self, cls, name):
|
||||||
"""Adds custom_key_field to the model class to save additional status information."""
|
"""Adds custom_key_field to the model class to save additional status information."""
|
||||||
|
from generic.states.validators import CustomStatusCodeValidator
|
||||||
|
|
||||||
|
validators = []
|
||||||
|
|
||||||
|
if self.status_class:
|
||||||
|
validators.append(CustomStatusCodeValidator(status_class=self.status_class))
|
||||||
|
|
||||||
custom_key_field = ExtraInvenTreeCustomStatusModelField(
|
custom_key_field = ExtraInvenTreeCustomStatusModelField(
|
||||||
default=None,
|
default=None,
|
||||||
verbose_name=_('Custom status key'),
|
verbose_name=_('Custom status key'),
|
||||||
help_text=_('Additional status information for this item'),
|
help_text=_('Additional status information for this item'),
|
||||||
|
validators=validators,
|
||||||
blank=True,
|
blank=True,
|
||||||
null=True,
|
null=True,
|
||||||
)
|
)
|
||||||
@ -130,6 +153,10 @@ class ExtraInvenTreeCustomStatusModelField(models.PositiveIntegerField):
|
|||||||
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.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
"""Initialize the field."""
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class InvenTreeCustomStatusSerializerMixin:
|
class InvenTreeCustomStatusSerializerMixin:
|
||||||
"""Mixin to ensure custom status fields are set.
|
"""Mixin to ensure custom status fields are set.
|
||||||
|
41
src/backend/InvenTree/generic/states/serializers.py
Normal file
41
src/backend/InvenTree/generic/states/serializers.py
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
"""Serializer classes for handling generic state information."""
|
||||||
|
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
|
||||||
|
class GenericStateValueSerializer(serializers.Serializer):
|
||||||
|
"""API serializer for generic state information."""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
"""Meta class for GenericStateValueSerializer."""
|
||||||
|
|
||||||
|
fields = ['key', 'logical_key', 'name', 'label', 'color', 'custom']
|
||||||
|
|
||||||
|
key = serializers.IntegerField(label=_('Key'), required=True)
|
||||||
|
|
||||||
|
logical_key = serializers.CharField(label=_('Logical Key'), required=False)
|
||||||
|
|
||||||
|
name = serializers.CharField(label=_('Name'), required=True)
|
||||||
|
|
||||||
|
label = serializers.CharField(label=_('Label'), required=True)
|
||||||
|
|
||||||
|
color = serializers.CharField(label=_('Color'), required=False)
|
||||||
|
|
||||||
|
custom = serializers.BooleanField(label=_('Custom'), required=False)
|
||||||
|
|
||||||
|
|
||||||
|
class GenericStateClassSerializer(serializers.Serializer):
|
||||||
|
"""API serializer for generic state class information."""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
"""Meta class for GenericStateClassSerializer."""
|
||||||
|
|
||||||
|
fields = ['status_class', 'values']
|
||||||
|
|
||||||
|
status_class = serializers.CharField(label=_('Class'), read_only=True)
|
||||||
|
|
||||||
|
values = serializers.DictField(
|
||||||
|
child=GenericStateValueSerializer(), label=_('Values'), required=True
|
||||||
|
)
|
@ -1,9 +1,12 @@
|
|||||||
"""Generic implementation of status for InvenTree models."""
|
"""Generic implementation of status for InvenTree models."""
|
||||||
|
|
||||||
import enum
|
import enum
|
||||||
|
import logging
|
||||||
import re
|
import re
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
|
||||||
|
logger = logging.getLogger('inventree')
|
||||||
|
|
||||||
|
|
||||||
class BaseEnum(enum.IntEnum): # noqa: PLW1641
|
class BaseEnum(enum.IntEnum): # noqa: PLW1641
|
||||||
"""An `Enum` capabile of having its members have docstrings.
|
"""An `Enum` capabile of having its members have docstrings.
|
||||||
@ -102,10 +105,30 @@ class StatusCode(BaseEnum):
|
|||||||
return False
|
return False
|
||||||
return isinstance(value.value, int)
|
return isinstance(value.value, int)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def custom_queryset(cls):
|
||||||
|
"""Return a queryset of all custom values for this status class."""
|
||||||
|
from common.models import InvenTreeCustomUserStateModel
|
||||||
|
|
||||||
|
try:
|
||||||
|
return InvenTreeCustomUserStateModel.objects.filter(
|
||||||
|
reference_status=cls.__name__
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def custom_values(cls):
|
||||||
|
"""Return all user-defined custom values for this status class."""
|
||||||
|
if query := cls.custom_queryset():
|
||||||
|
return list(query)
|
||||||
|
return []
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def values(cls, key=None):
|
def values(cls, key=None):
|
||||||
"""Return a dict representation containing all required information."""
|
"""Return a dict representation containing all required information."""
|
||||||
elements = [itm for itm in cls if cls._is_element(itm.name)]
|
elements = [itm for itm in cls if cls._is_element(itm.name)]
|
||||||
|
|
||||||
if key is None:
|
if key is None:
|
||||||
return elements
|
return elements
|
||||||
|
|
||||||
@ -138,19 +161,28 @@ class StatusCode(BaseEnum):
|
|||||||
return re.sub(r'(?<!^)(?=[A-Z])', '_', ref_name).lower()
|
return re.sub(r'(?<!^)(?=[A-Z])', '_', ref_name).lower()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def items(cls):
|
def items(cls, custom=False):
|
||||||
"""All status code items."""
|
"""All status code items."""
|
||||||
return [(x.value, x.label) for x in cls.values()]
|
data = [(x.value, x.label) for x in cls.values()]
|
||||||
|
|
||||||
|
if custom:
|
||||||
|
try:
|
||||||
|
for item in cls.custom_values():
|
||||||
|
data.append((item.key, item.label))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def keys(cls):
|
def keys(cls, custom=True):
|
||||||
"""All status code keys."""
|
"""All status code keys."""
|
||||||
return [x.value for x in cls.values()]
|
return [el[0] for el in cls.items(custom=custom)]
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def labels(cls):
|
def labels(cls, custom=True):
|
||||||
"""All status code labels."""
|
"""All status code labels."""
|
||||||
return [x.label for x in cls.values()]
|
return [el[1] for el in cls.items(custom=custom)]
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def names(cls):
|
def names(cls):
|
||||||
@ -174,23 +206,42 @@ class StatusCode(BaseEnum):
|
|||||||
return filtered.label
|
return filtered.label
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def dict(cls, key=None):
|
def dict(cls, key=None, custom=True):
|
||||||
"""Return a dict representation containing all required information."""
|
"""Return a dict representation containing all required information."""
|
||||||
return {
|
data = {
|
||||||
x.name: {'color': x.color, 'key': x.value, 'label': x.label, 'name': x.name}
|
x.name: {'color': x.color, 'key': x.value, 'label': x.label, 'name': x.name}
|
||||||
for x in cls.values(key)
|
for x in cls.values(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
if custom:
|
||||||
def list(cls):
|
try:
|
||||||
"""Return the StatusCode options as a list of mapped key / value items."""
|
for item in cls.custom_values():
|
||||||
return list(cls.dict().values())
|
if item.name not in data:
|
||||||
|
data[item.name] = {
|
||||||
|
'color': item.color,
|
||||||
|
'key': item.key,
|
||||||
|
'label': item.label,
|
||||||
|
'name': item.name,
|
||||||
|
'custom': True,
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def template_context(cls):
|
def list(cls, custom=True):
|
||||||
|
"""Return the StatusCode options as a list of mapped key / value items."""
|
||||||
|
return list(cls.dict(custom=custom).values())
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def template_context(cls, custom=True):
|
||||||
"""Return a dict representation containing all required information for templates."""
|
"""Return a dict representation containing all required information for templates."""
|
||||||
ret = {x.name: x.value for x in cls.values()}
|
data = cls.dict(custom=custom)
|
||||||
ret['list'] = cls.list()
|
|
||||||
|
ret = {x['name']: x['key'] for x in data.values()}
|
||||||
|
|
||||||
|
ret['list'] = list(data.values())
|
||||||
|
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
@ -205,3 +256,78 @@ class ColorEnum(Enum):
|
|||||||
warning = 'warning'
|
warning = 'warning'
|
||||||
info = 'info'
|
info = 'info'
|
||||||
dark = 'dark'
|
dark = 'dark'
|
||||||
|
|
||||||
|
|
||||||
|
class StatusCodeMixin:
|
||||||
|
"""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 '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,
|
||||||
|
which must be an instance of the InvenTreeCustomStatusModelField class.
|
||||||
|
"""
|
||||||
|
|
||||||
|
STATUS_CLASS = None
|
||||||
|
STATUS_FIELD = 'status'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def status_class(self):
|
||||||
|
"""Return the status class associated with this model."""
|
||||||
|
return self.STATUS_CLASS
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
"""Custom save method for StatusCodeMixin.
|
||||||
|
|
||||||
|
- Ensure custom status code values are correctly updated
|
||||||
|
"""
|
||||||
|
if self.status_class:
|
||||||
|
# Check that the current 'logical key' actually matches the current status code
|
||||||
|
custom_values = self.status_class.custom_queryset().filter(
|
||||||
|
logical_key=self.get_status(), key=self.get_custom_status()
|
||||||
|
)
|
||||||
|
|
||||||
|
if not custom_values.exists():
|
||||||
|
# No match - null out the custom value
|
||||||
|
setattr(self, f'{self.STATUS_FIELD}_custom_key', None)
|
||||||
|
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
def get_status(self) -> int:
|
||||||
|
"""Return the status code for this object."""
|
||||||
|
return getattr(self, self.STATUS_FIELD)
|
||||||
|
|
||||||
|
def get_custom_status(self) -> int:
|
||||||
|
"""Return the custom status code for this object."""
|
||||||
|
return getattr(self, f'{self.STATUS_FIELD}_custom_key', None)
|
||||||
|
|
||||||
|
def set_status(self, status: int) -> bool:
|
||||||
|
"""Set the status code for this object."""
|
||||||
|
if not self.status_class:
|
||||||
|
raise NotImplementedError('Status class not defined')
|
||||||
|
|
||||||
|
base_values = self.status_class.values()
|
||||||
|
custom_value_set = self.status_class.custom_values()
|
||||||
|
|
||||||
|
custom_field = f'{self.STATUS_FIELD}_custom_key'
|
||||||
|
|
||||||
|
result = False
|
||||||
|
|
||||||
|
if status in base_values:
|
||||||
|
# Set the status to a 'base' value
|
||||||
|
setattr(self, self.STATUS_FIELD, status)
|
||||||
|
setattr(self, custom_field, None)
|
||||||
|
result = True
|
||||||
|
else:
|
||||||
|
for item in custom_value_set:
|
||||||
|
if item.key == status:
|
||||||
|
# Set the status to a 'custom' value
|
||||||
|
setattr(self, self.STATUS_FIELD, item.logical_key)
|
||||||
|
setattr(self, custom_field, item.key)
|
||||||
|
result = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if not result:
|
||||||
|
logger.warning(f'Failed to set status {status} for class {self.__class__}')
|
||||||
|
|
||||||
|
return result
|
||||||
|
@ -174,10 +174,10 @@ class GeneralStateTest(InvenTreeTestCase):
|
|||||||
|
|
||||||
# Correct call
|
# Correct call
|
||||||
resp = view(rqst, **{StatusView.MODEL_REF: GeneralStatus})
|
resp = view(rqst, **{StatusView.MODEL_REF: GeneralStatus})
|
||||||
self.assertEqual(
|
self.assertDictEqual(
|
||||||
resp.data,
|
resp.data,
|
||||||
{
|
{
|
||||||
'class': 'GeneralStatus',
|
'status_class': 'GeneralStatus',
|
||||||
'values': {
|
'values': {
|
||||||
'COMPLETE': {
|
'COMPLETE': {
|
||||||
'key': 30,
|
'key': 30,
|
||||||
@ -228,11 +228,13 @@ class ApiTests(InvenTreeAPITestCase):
|
|||||||
def test_all_states(self):
|
def test_all_states(self):
|
||||||
"""Test the API endpoint for listing all status models."""
|
"""Test the API endpoint for listing all status models."""
|
||||||
response = self.get(reverse('api-status-all'))
|
response = self.get(reverse('api-status-all'))
|
||||||
|
|
||||||
|
# 10 built-in state classes, plus the added GeneralState class
|
||||||
self.assertEqual(len(response.data), 12)
|
self.assertEqual(len(response.data), 12)
|
||||||
|
|
||||||
# Test the BuildStatus model
|
# Test the BuildStatus model
|
||||||
build_status = response.data['BuildStatus']
|
build_status = response.data['BuildStatus']
|
||||||
self.assertEqual(build_status['class'], 'BuildStatus')
|
self.assertEqual(build_status['status_class'], 'BuildStatus')
|
||||||
self.assertEqual(len(build_status['values']), 5)
|
self.assertEqual(len(build_status['values']), 5)
|
||||||
pending = build_status['values']['PENDING']
|
pending = build_status['values']['PENDING']
|
||||||
self.assertEqual(pending['key'], 10)
|
self.assertEqual(pending['key'], 10)
|
||||||
@ -241,7 +243,7 @@ class ApiTests(InvenTreeAPITestCase):
|
|||||||
|
|
||||||
# Test the StockStatus model (static)
|
# Test the StockStatus model (static)
|
||||||
stock_status = response.data['StockStatus']
|
stock_status = response.data['StockStatus']
|
||||||
self.assertEqual(stock_status['class'], 'StockStatus')
|
self.assertEqual(stock_status['status_class'], 'StockStatus')
|
||||||
self.assertEqual(len(stock_status['values']), 8)
|
self.assertEqual(len(stock_status['values']), 8)
|
||||||
in_stock = stock_status['values']['OK']
|
in_stock = stock_status['values']['OK']
|
||||||
self.assertEqual(in_stock['key'], 10)
|
self.assertEqual(in_stock['key'], 10)
|
||||||
@ -249,8 +251,8 @@ class ApiTests(InvenTreeAPITestCase):
|
|||||||
self.assertEqual(in_stock['label'], 'OK')
|
self.assertEqual(in_stock['label'], 'OK')
|
||||||
|
|
||||||
# MachineStatus model
|
# MachineStatus model
|
||||||
machine_status = response.data['MachineStatus__LabelPrinterStatus']
|
machine_status = response.data['LabelPrinterStatus']
|
||||||
self.assertEqual(machine_status['class'], 'LabelPrinterStatus')
|
self.assertEqual(machine_status['status_class'], 'LabelPrinterStatus')
|
||||||
self.assertEqual(len(machine_status['values']), 6)
|
self.assertEqual(len(machine_status['values']), 6)
|
||||||
connected = machine_status['values']['CONNECTED']
|
connected = machine_status['values']['CONNECTED']
|
||||||
self.assertEqual(connected['key'], 100)
|
self.assertEqual(connected['key'], 100)
|
||||||
@ -267,10 +269,11 @@ class ApiTests(InvenTreeAPITestCase):
|
|||||||
reference_status='StockStatus',
|
reference_status='StockStatus',
|
||||||
)
|
)
|
||||||
response = self.get(reverse('api-status-all'))
|
response = self.get(reverse('api-status-all'))
|
||||||
|
|
||||||
self.assertEqual(len(response.data), 12)
|
self.assertEqual(len(response.data), 12)
|
||||||
|
|
||||||
stock_status_cstm = response.data['StockStatus']
|
stock_status_cstm = response.data['StockStatus']
|
||||||
self.assertEqual(stock_status_cstm['class'], 'StockStatus')
|
self.assertEqual(stock_status_cstm['status_class'], 'StockStatus')
|
||||||
self.assertEqual(len(stock_status_cstm['values']), 9)
|
self.assertEqual(len(stock_status_cstm['values']), 9)
|
||||||
ok_advanced = stock_status_cstm['values']['OK']
|
ok_advanced = stock_status_cstm['values']['OK']
|
||||||
self.assertEqual(ok_advanced['key'], 10)
|
self.assertEqual(ok_advanced['key'], 10)
|
||||||
|
21
src/backend/InvenTree/generic/states/validators.py
Normal file
21
src/backend/InvenTree/generic/states/validators.py
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
"""Validators for generic state management."""
|
||||||
|
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.core.validators import BaseValidator
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
|
||||||
|
class CustomStatusCodeValidator(BaseValidator):
|
||||||
|
"""Custom validator class for checking that a provided status code is valid."""
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
"""Initialize the validator."""
|
||||||
|
self.status_class = kwargs.pop('status_class', None)
|
||||||
|
super().__init__(limit_value=None, **kwargs)
|
||||||
|
|
||||||
|
def __call__(self, value):
|
||||||
|
"""Check that the provided status code is valid."""
|
||||||
|
if status_class := self.status_class:
|
||||||
|
values = status_class.keys(custom=True)
|
||||||
|
if value not in values:
|
||||||
|
raise ValidationError(_('Invalid status code'))
|
@ -82,8 +82,14 @@ class OrderFilter(rest_filters.FilterSet):
|
|||||||
status = rest_filters.NumberFilter(label=_('Order Status'), method='filter_status')
|
status = rest_filters.NumberFilter(label=_('Order Status'), method='filter_status')
|
||||||
|
|
||||||
def filter_status(self, queryset, name, value):
|
def filter_status(self, queryset, name, value):
|
||||||
"""Filter by integer status code."""
|
"""Filter by integer status code.
|
||||||
return queryset.filter(status=value)
|
|
||||||
|
Note: Also account for the possibility of a custom status code.
|
||||||
|
"""
|
||||||
|
q1 = Q(status=value, status_custom_key__isnull=True)
|
||||||
|
q2 = Q(status_custom_key=value)
|
||||||
|
|
||||||
|
return queryset.filter(q1 | q2).distinct()
|
||||||
|
|
||||||
# Exact match for reference
|
# Exact match for reference
|
||||||
reference = rest_filters.CharFilter(
|
reference = rest_filters.CharFilter(
|
||||||
|
@ -3,6 +3,7 @@
|
|||||||
from django.db import migrations
|
from django.db import migrations
|
||||||
|
|
||||||
import generic.states.fields
|
import generic.states.fields
|
||||||
|
import generic.states.validators
|
||||||
import InvenTree.status_codes
|
import InvenTree.status_codes
|
||||||
|
|
||||||
|
|
||||||
@ -22,6 +23,11 @@ class Migration(migrations.Migration):
|
|||||||
help_text="Additional status information for this item",
|
help_text="Additional status information for this item",
|
||||||
null=True,
|
null=True,
|
||||||
verbose_name="Custom status key",
|
verbose_name="Custom status key",
|
||||||
|
validators=[
|
||||||
|
generic.states.validators.CustomStatusCodeValidator(
|
||||||
|
status_class=InvenTree.status_codes.PurchaseOrderStatus
|
||||||
|
),
|
||||||
|
]
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
@ -33,6 +39,11 @@ class Migration(migrations.Migration):
|
|||||||
help_text="Additional status information for this item",
|
help_text="Additional status information for this item",
|
||||||
null=True,
|
null=True,
|
||||||
verbose_name="Custom status key",
|
verbose_name="Custom status key",
|
||||||
|
validators=[
|
||||||
|
generic.states.validators.CustomStatusCodeValidator(
|
||||||
|
status_class=InvenTree.status_codes.ReturnOrderStatus
|
||||||
|
),
|
||||||
|
]
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
@ -44,6 +55,11 @@ class Migration(migrations.Migration):
|
|||||||
help_text="Additional status information for this item",
|
help_text="Additional status information for this item",
|
||||||
null=True,
|
null=True,
|
||||||
verbose_name="Custom status key",
|
verbose_name="Custom status key",
|
||||||
|
validators=[
|
||||||
|
generic.states.validators.CustomStatusCodeValidator(
|
||||||
|
status_class=InvenTree.status_codes.ReturnOrderLineStatus
|
||||||
|
),
|
||||||
|
]
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
@ -55,6 +71,11 @@ class Migration(migrations.Migration):
|
|||||||
help_text="Additional status information for this item",
|
help_text="Additional status information for this item",
|
||||||
null=True,
|
null=True,
|
||||||
verbose_name="Custom status key",
|
verbose_name="Custom status key",
|
||||||
|
validators=[
|
||||||
|
generic.states.validators.CustomStatusCodeValidator(
|
||||||
|
status_class=InvenTree.status_codes.SalesOrderStatus
|
||||||
|
),
|
||||||
|
]
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
@ -65,6 +86,11 @@ class Migration(migrations.Migration):
|
|||||||
default=10,
|
default=10,
|
||||||
help_text="Purchase order status",
|
help_text="Purchase order status",
|
||||||
verbose_name="Status",
|
verbose_name="Status",
|
||||||
|
validators=[
|
||||||
|
generic.states.validators.CustomStatusCodeValidator(
|
||||||
|
status_class=InvenTree.status_codes.PurchaseOrderStatus
|
||||||
|
),
|
||||||
|
]
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
@ -75,6 +101,11 @@ class Migration(migrations.Migration):
|
|||||||
default=10,
|
default=10,
|
||||||
help_text="Return order status",
|
help_text="Return order status",
|
||||||
verbose_name="Status",
|
verbose_name="Status",
|
||||||
|
validators=[
|
||||||
|
generic.states.validators.CustomStatusCodeValidator(
|
||||||
|
status_class=InvenTree.status_codes.ReturnOrderStatus
|
||||||
|
),
|
||||||
|
]
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
@ -85,6 +116,11 @@ class Migration(migrations.Migration):
|
|||||||
default=10,
|
default=10,
|
||||||
help_text="Outcome for this line item",
|
help_text="Outcome for this line item",
|
||||||
verbose_name="Outcome",
|
verbose_name="Outcome",
|
||||||
|
validators=[
|
||||||
|
generic.states.validators.CustomStatusCodeValidator(
|
||||||
|
status_class=InvenTree.status_codes.ReturnOrderLineStatus
|
||||||
|
),
|
||||||
|
]
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
@ -95,6 +131,11 @@ class Migration(migrations.Migration):
|
|||||||
default=10,
|
default=10,
|
||||||
help_text="Sales order status",
|
help_text="Sales order status",
|
||||||
verbose_name="Status",
|
verbose_name="Status",
|
||||||
|
validators=[
|
||||||
|
generic.states.validators.CustomStatusCodeValidator(
|
||||||
|
status_class=InvenTree.status_codes.SalesOrderStatus
|
||||||
|
),
|
||||||
|
]
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
@ -34,7 +34,7 @@ from common.currency import currency_code_default
|
|||||||
from common.notifications import InvenTreeNotificationBodies
|
from common.notifications import InvenTreeNotificationBodies
|
||||||
from common.settings import get_global_setting
|
from common.settings import get_global_setting
|
||||||
from company.models import Address, Company, Contact, SupplierPart
|
from company.models import Address, Company, Contact, SupplierPart
|
||||||
from generic.states import StateTransitionMixin
|
from generic.states import StateTransitionMixin, StatusCodeMixin
|
||||||
from generic.states.fields import InvenTreeCustomStatusModelField
|
from generic.states.fields import InvenTreeCustomStatusModelField
|
||||||
from InvenTree.exceptions import log_error
|
from InvenTree.exceptions import log_error
|
||||||
from InvenTree.fields import (
|
from InvenTree.fields import (
|
||||||
@ -179,6 +179,7 @@ class TotalPriceMixin(models.Model):
|
|||||||
|
|
||||||
|
|
||||||
class Order(
|
class Order(
|
||||||
|
StatusCodeMixin,
|
||||||
StateTransitionMixin,
|
StateTransitionMixin,
|
||||||
InvenTree.models.InvenTreeAttachmentMixin,
|
InvenTree.models.InvenTreeAttachmentMixin,
|
||||||
InvenTree.models.InvenTreeBarcodeMixin,
|
InvenTree.models.InvenTreeBarcodeMixin,
|
||||||
@ -379,6 +380,7 @@ class PurchaseOrder(TotalPriceMixin, Order):
|
|||||||
|
|
||||||
REFERENCE_PATTERN_SETTING = 'PURCHASEORDER_REFERENCE_PATTERN'
|
REFERENCE_PATTERN_SETTING = 'PURCHASEORDER_REFERENCE_PATTERN'
|
||||||
REQUIRE_RESPONSIBLE_SETTING = 'PURCHASEORDER_REQUIRE_RESPONSIBLE'
|
REQUIRE_RESPONSIBLE_SETTING = 'PURCHASEORDER_REQUIRE_RESPONSIBLE'
|
||||||
|
STATUS_CLASS = PurchaseOrderStatus
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
"""Model meta options."""
|
"""Model meta options."""
|
||||||
@ -483,6 +485,7 @@ class PurchaseOrder(TotalPriceMixin, Order):
|
|||||||
status = InvenTreeCustomStatusModelField(
|
status = InvenTreeCustomStatusModelField(
|
||||||
default=PurchaseOrderStatus.PENDING.value,
|
default=PurchaseOrderStatus.PENDING.value,
|
||||||
choices=PurchaseOrderStatus.items(),
|
choices=PurchaseOrderStatus.items(),
|
||||||
|
status_class=PurchaseOrderStatus,
|
||||||
verbose_name=_('Status'),
|
verbose_name=_('Status'),
|
||||||
help_text=_('Purchase order status'),
|
help_text=_('Purchase order status'),
|
||||||
)
|
)
|
||||||
@ -912,6 +915,7 @@ class SalesOrder(TotalPriceMixin, Order):
|
|||||||
|
|
||||||
REFERENCE_PATTERN_SETTING = 'SALESORDER_REFERENCE_PATTERN'
|
REFERENCE_PATTERN_SETTING = 'SALESORDER_REFERENCE_PATTERN'
|
||||||
REQUIRE_RESPONSIBLE_SETTING = 'SALESORDER_REQUIRE_RESPONSIBLE'
|
REQUIRE_RESPONSIBLE_SETTING = 'SALESORDER_REQUIRE_RESPONSIBLE'
|
||||||
|
STATUS_CLASS = SalesOrderStatus
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
"""Model meta options."""
|
"""Model meta options."""
|
||||||
@ -1029,6 +1033,7 @@ class SalesOrder(TotalPriceMixin, Order):
|
|||||||
status = InvenTreeCustomStatusModelField(
|
status = InvenTreeCustomStatusModelField(
|
||||||
default=SalesOrderStatus.PENDING.value,
|
default=SalesOrderStatus.PENDING.value,
|
||||||
choices=SalesOrderStatus.items(),
|
choices=SalesOrderStatus.items(),
|
||||||
|
status_class=SalesOrderStatus,
|
||||||
verbose_name=_('Status'),
|
verbose_name=_('Status'),
|
||||||
help_text=_('Sales order status'),
|
help_text=_('Sales order status'),
|
||||||
)
|
)
|
||||||
@ -2155,6 +2160,7 @@ class ReturnOrder(TotalPriceMixin, Order):
|
|||||||
|
|
||||||
REFERENCE_PATTERN_SETTING = 'RETURNORDER_REFERENCE_PATTERN'
|
REFERENCE_PATTERN_SETTING = 'RETURNORDER_REFERENCE_PATTERN'
|
||||||
REQUIRE_RESPONSIBLE_SETTING = 'RETURNORDER_REQUIRE_RESPONSIBLE'
|
REQUIRE_RESPONSIBLE_SETTING = 'RETURNORDER_REQUIRE_RESPONSIBLE'
|
||||||
|
STATUS_CLASS = ReturnOrderStatus
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
"""Model meta options."""
|
"""Model meta options."""
|
||||||
@ -2231,6 +2237,7 @@ class ReturnOrder(TotalPriceMixin, Order):
|
|||||||
status = InvenTreeCustomStatusModelField(
|
status = InvenTreeCustomStatusModelField(
|
||||||
default=ReturnOrderStatus.PENDING.value,
|
default=ReturnOrderStatus.PENDING.value,
|
||||||
choices=ReturnOrderStatus.items(),
|
choices=ReturnOrderStatus.items(),
|
||||||
|
status_class=ReturnOrderStatus,
|
||||||
verbose_name=_('Status'),
|
verbose_name=_('Status'),
|
||||||
help_text=_('Return order status'),
|
help_text=_('Return order status'),
|
||||||
)
|
)
|
||||||
@ -2446,9 +2453,12 @@ class ReturnOrder(TotalPriceMixin, Order):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class ReturnOrderLineItem(OrderLineItem):
|
class ReturnOrderLineItem(StatusCodeMixin, OrderLineItem):
|
||||||
"""Model for a single LineItem in a ReturnOrder."""
|
"""Model for a single LineItem in a ReturnOrder."""
|
||||||
|
|
||||||
|
STATUS_CLASS = ReturnOrderLineStatus
|
||||||
|
STATUS_FIELD = 'outcome'
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
"""Metaclass options for this model."""
|
"""Metaclass options for this model."""
|
||||||
|
|
||||||
@ -2522,6 +2532,7 @@ class ReturnOrderLineItem(OrderLineItem):
|
|||||||
outcome = InvenTreeCustomStatusModelField(
|
outcome = InvenTreeCustomStatusModelField(
|
||||||
default=ReturnOrderLineStatus.PENDING.value,
|
default=ReturnOrderLineStatus.PENDING.value,
|
||||||
choices=ReturnOrderLineStatus.items(),
|
choices=ReturnOrderLineStatus.items(),
|
||||||
|
status_class=ReturnOrderLineStatus,
|
||||||
verbose_name=_('Outcome'),
|
verbose_name=_('Outcome'),
|
||||||
help_text=_('Outcome for this line item'),
|
help_text=_('Outcome for this line item'),
|
||||||
)
|
)
|
||||||
|
@ -769,7 +769,9 @@ class PurchaseOrderLineItemReceiveSerializer(serializers.Serializer):
|
|||||||
)
|
)
|
||||||
|
|
||||||
status = serializers.ChoiceField(
|
status = serializers.ChoiceField(
|
||||||
choices=StockStatus.items(), default=StockStatus.OK.value, label=_('Status')
|
choices=StockStatus.items(custom=True),
|
||||||
|
default=StockStatus.OK.value,
|
||||||
|
label=_('Status'),
|
||||||
)
|
)
|
||||||
|
|
||||||
packaging = serializers.CharField(
|
packaging = serializers.CharField(
|
||||||
@ -1935,7 +1937,7 @@ class ReturnOrderLineItemReceiveSerializer(serializers.Serializer):
|
|||||||
)
|
)
|
||||||
|
|
||||||
status = serializers.ChoiceField(
|
status = serializers.ChoiceField(
|
||||||
choices=stock.status_codes.StockStatus.items(),
|
choices=stock.status_codes.StockStatus.items(custom=True),
|
||||||
default=None,
|
default=None,
|
||||||
label=_('Status'),
|
label=_('Status'),
|
||||||
help_text=_('Stock item status code'),
|
help_text=_('Stock item status code'),
|
||||||
|
@ -571,8 +571,14 @@ class StockFilter(rest_filters.FilterSet):
|
|||||||
status = rest_filters.NumberFilter(label=_('Status Code'), method='filter_status')
|
status = rest_filters.NumberFilter(label=_('Status Code'), method='filter_status')
|
||||||
|
|
||||||
def filter_status(self, queryset, name, value):
|
def filter_status(self, queryset, name, value):
|
||||||
"""Filter by integer status code."""
|
"""Filter by integer status code.
|
||||||
return queryset.filter(status=value)
|
|
||||||
|
Note: Also account for the possibility of a custom status code.
|
||||||
|
"""
|
||||||
|
q1 = Q(status=value, status_custom_key__isnull=True)
|
||||||
|
q2 = Q(status_custom_key=value)
|
||||||
|
|
||||||
|
return queryset.filter(q1 | q2).distinct()
|
||||||
|
|
||||||
allocated = rest_filters.BooleanFilter(
|
allocated = rest_filters.BooleanFilter(
|
||||||
label='Is Allocated', method='filter_allocated'
|
label='Is Allocated', method='filter_allocated'
|
||||||
|
@ -5,6 +5,7 @@ from django.db import migrations
|
|||||||
|
|
||||||
import generic.states
|
import generic.states
|
||||||
import generic.states.fields
|
import generic.states.fields
|
||||||
|
import generic.states.validators
|
||||||
import InvenTree.status_codes
|
import InvenTree.status_codes
|
||||||
|
|
||||||
|
|
||||||
@ -24,6 +25,11 @@ class Migration(migrations.Migration):
|
|||||||
help_text="Additional status information for this item",
|
help_text="Additional status information for this item",
|
||||||
null=True,
|
null=True,
|
||||||
verbose_name="Custom status key",
|
verbose_name="Custom status key",
|
||||||
|
validators=[
|
||||||
|
generic.states.validators.CustomStatusCodeValidator(
|
||||||
|
status_class=InvenTree.status_codes.StockStatus
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
@ -32,7 +38,12 @@ class Migration(migrations.Migration):
|
|||||||
field=generic.states.fields.InvenTreeCustomStatusModelField(
|
field=generic.states.fields.InvenTreeCustomStatusModelField(
|
||||||
choices=InvenTree.status_codes.StockStatus.items(),
|
choices=InvenTree.status_codes.StockStatus.items(),
|
||||||
default=10,
|
default=10,
|
||||||
validators=[django.core.validators.MinValueValidator(0)],
|
validators=[
|
||||||
|
django.core.validators.MinValueValidator(0),
|
||||||
|
generic.states.validators.CustomStatusCodeValidator(
|
||||||
|
status_class=InvenTree.status_codes.StockStatus
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
@ -37,6 +37,7 @@ import stock.tasks
|
|||||||
from common.icons import validate_icon
|
from common.icons import validate_icon
|
||||||
from common.settings import get_global_setting
|
from common.settings import get_global_setting
|
||||||
from company import models as CompanyModels
|
from company import models as CompanyModels
|
||||||
|
from generic.states import StatusCodeMixin
|
||||||
from generic.states.fields import InvenTreeCustomStatusModelField
|
from generic.states.fields import InvenTreeCustomStatusModelField
|
||||||
from InvenTree.fields import InvenTreeModelMoneyField, InvenTreeURLField
|
from InvenTree.fields import InvenTreeModelMoneyField, InvenTreeURLField
|
||||||
from InvenTree.status_codes import (
|
from InvenTree.status_codes import (
|
||||||
@ -340,6 +341,7 @@ class StockItem(
|
|||||||
InvenTree.models.InvenTreeAttachmentMixin,
|
InvenTree.models.InvenTreeAttachmentMixin,
|
||||||
InvenTree.models.InvenTreeBarcodeMixin,
|
InvenTree.models.InvenTreeBarcodeMixin,
|
||||||
InvenTree.models.InvenTreeNotesMixin,
|
InvenTree.models.InvenTreeNotesMixin,
|
||||||
|
StatusCodeMixin,
|
||||||
report.mixins.InvenTreeReportMixin,
|
report.mixins.InvenTreeReportMixin,
|
||||||
InvenTree.models.MetadataMixin,
|
InvenTree.models.MetadataMixin,
|
||||||
InvenTree.models.PluginValidationMixin,
|
InvenTree.models.PluginValidationMixin,
|
||||||
@ -373,6 +375,8 @@ class StockItem(
|
|||||||
packaging: Description of how the StockItem is packaged (e.g. "reel", "loose", "tape" etc)
|
packaging: Description of how the StockItem is packaged (e.g. "reel", "loose", "tape" etc)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
STATUS_CLASS = StockStatus
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
"""Model meta options."""
|
"""Model meta options."""
|
||||||
|
|
||||||
@ -1020,6 +1024,7 @@ class StockItem(
|
|||||||
|
|
||||||
status = InvenTreeCustomStatusModelField(
|
status = InvenTreeCustomStatusModelField(
|
||||||
default=StockStatus.OK.value,
|
default=StockStatus.OK.value,
|
||||||
|
status_class=StockStatus,
|
||||||
choices=StockStatus.items(),
|
choices=StockStatus.items(),
|
||||||
validators=[MinValueValidator(0)],
|
validators=[MinValueValidator(0)],
|
||||||
)
|
)
|
||||||
@ -2137,6 +2142,12 @@ class StockItem(
|
|||||||
else:
|
else:
|
||||||
tracking_info['location'] = location.pk
|
tracking_info['location'] = location.pk
|
||||||
|
|
||||||
|
status = kwargs.pop('status', None) or kwargs.pop('status_custom_key', None)
|
||||||
|
|
||||||
|
if status and status != self.status:
|
||||||
|
self.set_status(status)
|
||||||
|
tracking_info['status'] = status
|
||||||
|
|
||||||
# Optional fields which can be supplied in a 'move' call
|
# Optional fields which can be supplied in a 'move' call
|
||||||
for field in StockItem.optional_transfer_fields():
|
for field in StockItem.optional_transfer_fields():
|
||||||
if field in kwargs:
|
if field in kwargs:
|
||||||
@ -2214,8 +2225,16 @@ class StockItem(
|
|||||||
if count < 0:
|
if count < 0:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
tracking_info = {}
|
||||||
|
|
||||||
|
status = kwargs.pop('status', None) or kwargs.pop('status_custom_key', None)
|
||||||
|
|
||||||
|
if status and status != self.status:
|
||||||
|
self.set_status(status)
|
||||||
|
tracking_info['status'] = status
|
||||||
|
|
||||||
if self.updateQuantity(count):
|
if self.updateQuantity(count):
|
||||||
tracking_info = {'quantity': float(count)}
|
tracking_info['quantity'] = float(count)
|
||||||
|
|
||||||
self.stocktake_date = InvenTree.helpers.current_date()
|
self.stocktake_date = InvenTree.helpers.current_date()
|
||||||
self.stocktake_user = user
|
self.stocktake_user = user
|
||||||
@ -2269,8 +2288,17 @@ class StockItem(
|
|||||||
if quantity <= 0:
|
if quantity <= 0:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
tracking_info = {}
|
||||||
|
|
||||||
|
status = kwargs.pop('status', None) or kwargs.pop('status_custom_key', None)
|
||||||
|
|
||||||
|
if status and status != self.status:
|
||||||
|
self.set_status(status)
|
||||||
|
tracking_info['status'] = status
|
||||||
|
|
||||||
if self.updateQuantity(self.quantity + quantity):
|
if self.updateQuantity(self.quantity + quantity):
|
||||||
tracking_info = {'added': float(quantity), 'quantity': float(self.quantity)}
|
tracking_info['added'] = float(quantity)
|
||||||
|
tracking_info['quantity'] = float(self.quantity)
|
||||||
|
|
||||||
# Optional fields which can be supplied in a 'stocktake' call
|
# Optional fields which can be supplied in a 'stocktake' call
|
||||||
for field in StockItem.optional_transfer_fields():
|
for field in StockItem.optional_transfer_fields():
|
||||||
@ -2314,8 +2342,17 @@ class StockItem(
|
|||||||
if quantity <= 0:
|
if quantity <= 0:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
deltas = {}
|
||||||
|
|
||||||
|
status = kwargs.pop('status', None) or kwargs.pop('status_custom_key', None)
|
||||||
|
|
||||||
|
if status and status != self.status:
|
||||||
|
self.set_status(status)
|
||||||
|
deltas['status'] = status
|
||||||
|
|
||||||
if self.updateQuantity(self.quantity - quantity):
|
if self.updateQuantity(self.quantity - quantity):
|
||||||
deltas = {'removed': float(quantity), 'quantity': float(self.quantity)}
|
deltas['removed'] = float(quantity)
|
||||||
|
deltas['quantity'] = float(self.quantity)
|
||||||
|
|
||||||
if location := kwargs.get('location'):
|
if location := kwargs.get('location'):
|
||||||
deltas['location'] = location.pk
|
deltas['location'] = location.pk
|
||||||
|
@ -980,7 +980,7 @@ class ReturnStockItemSerializer(serializers.Serializer):
|
|||||||
)
|
)
|
||||||
|
|
||||||
status = serializers.ChoiceField(
|
status = serializers.ChoiceField(
|
||||||
choices=stock.status_codes.StockStatus.items(),
|
choices=stock.status_codes.StockStatus.items(custom=True),
|
||||||
default=None,
|
default=None,
|
||||||
label=_('Status'),
|
label=_('Status'),
|
||||||
help_text=_('Stock item status code'),
|
help_text=_('Stock item status code'),
|
||||||
@ -996,7 +996,7 @@ class ReturnStockItemSerializer(serializers.Serializer):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def save(self):
|
def save(self):
|
||||||
"""Save the serialzier to return the item into stock."""
|
"""Save the serializer to return the item into stock."""
|
||||||
item = self.context['item']
|
item = self.context['item']
|
||||||
request = self.context['request']
|
request = self.context['request']
|
||||||
|
|
||||||
@ -1037,7 +1037,7 @@ class StockChangeStatusSerializer(serializers.Serializer):
|
|||||||
return items
|
return items
|
||||||
|
|
||||||
status = serializers.ChoiceField(
|
status = serializers.ChoiceField(
|
||||||
choices=stock.status_codes.StockStatus.items(),
|
choices=stock.status_codes.StockStatus.items(custom=True),
|
||||||
default=stock.status_codes.StockStatus.OK.value,
|
default=stock.status_codes.StockStatus.OK.value,
|
||||||
label=_('Status'),
|
label=_('Status'),
|
||||||
)
|
)
|
||||||
@ -1533,11 +1533,11 @@ def stock_item_adjust_status_options():
|
|||||||
|
|
||||||
In particular, include a Null option for the status field.
|
In particular, include a Null option for the status field.
|
||||||
"""
|
"""
|
||||||
return [(None, _('No Change')), *stock.status_codes.StockStatus.items()]
|
return [(None, _('No Change')), *stock.status_codes.StockStatus.items(custom=True)]
|
||||||
|
|
||||||
|
|
||||||
class StockAdjustmentItemSerializer(serializers.Serializer):
|
class StockAdjustmentItemSerializer(serializers.Serializer):
|
||||||
"""Serializer for a single StockItem within a stock adjument request.
|
"""Serializer for a single StockItem within a stock adjustment request.
|
||||||
|
|
||||||
Required Fields:
|
Required Fields:
|
||||||
- item: StockItem object
|
- item: StockItem object
|
||||||
|
@ -1,19 +1,22 @@
|
|||||||
import { Badge, Center, type MantineSize } from '@mantine/core';
|
import { Badge, Center, type MantineSize } from '@mantine/core';
|
||||||
|
|
||||||
import { colorMap } from '../../defaults/backendMappings';
|
import { statusColorMap } from '../../defaults/backendMappings';
|
||||||
import type { ModelType } from '../../enums/ModelType';
|
import type { ModelType } from '../../enums/ModelType';
|
||||||
import { resolveItem } from '../../functions/conversion';
|
import { resolveItem } from '../../functions/conversion';
|
||||||
import { useGlobalStatusState } from '../../states/StatusState';
|
import { useGlobalStatusState } from '../../states/StatusState';
|
||||||
|
|
||||||
interface StatusCodeInterface {
|
export interface StatusCodeInterface {
|
||||||
key: string;
|
key: number;
|
||||||
label: string;
|
label: string;
|
||||||
name: string;
|
name: string;
|
||||||
color: string;
|
color: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StatusCodeListInterface {
|
export interface StatusCodeListInterface {
|
||||||
[key: string]: StatusCodeInterface;
|
status_class: string;
|
||||||
|
values: {
|
||||||
|
[key: string]: StatusCodeInterface;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RenderStatusLabelOptionsInterface {
|
interface RenderStatusLabelOptionsInterface {
|
||||||
@ -33,10 +36,10 @@ function renderStatusLabel(
|
|||||||
let color = null;
|
let color = null;
|
||||||
|
|
||||||
// Find the entry which matches the provided key
|
// Find the entry which matches the provided key
|
||||||
for (const name in codes) {
|
for (const name in codes.values) {
|
||||||
const entry = codes[name];
|
const entry: StatusCodeInterface = codes.values[name];
|
||||||
|
|
||||||
if (entry.key == key) {
|
if (entry?.key == key) {
|
||||||
text = entry.label;
|
text = entry.label;
|
||||||
color = entry.color;
|
color = entry.color;
|
||||||
break;
|
break;
|
||||||
@ -51,7 +54,7 @@ function renderStatusLabel(
|
|||||||
|
|
||||||
// Fallbacks
|
// Fallbacks
|
||||||
if (color == null) color = 'default';
|
if (color == null) color = 'default';
|
||||||
color = colorMap[color] || colorMap['default'];
|
color = statusColorMap[color] || statusColorMap['default'];
|
||||||
const size = options.size || 'xs';
|
const size = options.size || 'xs';
|
||||||
|
|
||||||
if (!text) {
|
if (!text) {
|
||||||
@ -65,7 +68,9 @@ function renderStatusLabel(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getStatusCodes(type: ModelType | string) {
|
export function getStatusCodes(
|
||||||
|
type: ModelType | string
|
||||||
|
): StatusCodeListInterface | null {
|
||||||
const statusCodeList = useGlobalStatusState.getState().status;
|
const statusCodeList = useGlobalStatusState.getState().status;
|
||||||
|
|
||||||
if (statusCodeList === undefined) {
|
if (statusCodeList === undefined) {
|
||||||
@ -97,7 +102,7 @@ export function getStatusCodeName(
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const name in statusCodes) {
|
for (const name in statusCodes) {
|
||||||
const entry = statusCodes[name];
|
const entry: StatusCodeInterface = statusCodes.values[name];
|
||||||
|
|
||||||
if (entry.key == key) {
|
if (entry.key == key) {
|
||||||
return entry.name;
|
return entry.name;
|
||||||
|
@ -20,7 +20,7 @@ export const statusCodeList: Record<string, ModelType> = {
|
|||||||
/*
|
/*
|
||||||
* Map the colors used in the backend to the colors used in the frontend
|
* Map the colors used in the backend to the colors used in the frontend
|
||||||
*/
|
*/
|
||||||
export const colorMap: { [key: string]: string } = {
|
export const statusColorMap: { [key: string]: string } = {
|
||||||
dark: 'dark',
|
dark: 'dark',
|
||||||
warning: 'yellow',
|
warning: 'yellow',
|
||||||
success: 'green',
|
success: 'green',
|
||||||
|
@ -1,6 +1,12 @@
|
|||||||
import { IconUsers } from '@tabler/icons-react';
|
import { IconUsers } from '@tabler/icons-react';
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
|
||||||
import type { ApiFormFieldSet } from '../components/forms/fields/ApiFormField';
|
import type { ApiFormFieldSet } from '../components/forms/fields/ApiFormField';
|
||||||
|
import type {
|
||||||
|
StatusCodeInterface,
|
||||||
|
StatusCodeListInterface
|
||||||
|
} from '../components/render/StatusRenderer';
|
||||||
|
import { useGlobalStatusState } from '../states/StatusState';
|
||||||
|
|
||||||
export function projectCodeFields(): ApiFormFieldSet {
|
export function projectCodeFields(): ApiFormFieldSet {
|
||||||
return {
|
return {
|
||||||
@ -12,16 +18,51 @@ export function projectCodeFields(): ApiFormFieldSet {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function customStateFields(): ApiFormFieldSet {
|
export function useCustomStateFields(): ApiFormFieldSet {
|
||||||
return {
|
// Status codes
|
||||||
key: {},
|
const statusCodes = useGlobalStatusState();
|
||||||
name: {},
|
|
||||||
label: {},
|
// Selected base status class
|
||||||
color: {},
|
const [statusClass, setStatusClass] = useState<string>('');
|
||||||
logical_key: {},
|
|
||||||
model: {},
|
// Construct a list of status options based on the selected status class
|
||||||
reference_status: {}
|
const statusOptions: any[] = useMemo(() => {
|
||||||
};
|
const options: any[] = [];
|
||||||
|
|
||||||
|
const valuesList = Object.values(statusCodes.status ?? {}).find(
|
||||||
|
(value: StatusCodeListInterface) => value.status_class === statusClass
|
||||||
|
);
|
||||||
|
|
||||||
|
Object.values(valuesList?.values ?? {}).forEach(
|
||||||
|
(value: StatusCodeInterface) => {
|
||||||
|
options.push({
|
||||||
|
value: value.key,
|
||||||
|
display_name: value.label
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return options;
|
||||||
|
}, [statusCodes, statusClass]);
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
return {
|
||||||
|
reference_status: {
|
||||||
|
onValueChange(value) {
|
||||||
|
setStatusClass(value);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
logical_key: {
|
||||||
|
field_type: 'choice',
|
||||||
|
choices: statusOptions
|
||||||
|
},
|
||||||
|
key: {},
|
||||||
|
name: {},
|
||||||
|
label: {},
|
||||||
|
color: {},
|
||||||
|
model: {}
|
||||||
|
};
|
||||||
|
}, [statusOptions]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function customUnitsFields(): ApiFormFieldSet {
|
export function customUnitsFields(): ApiFormFieldSet {
|
||||||
|
@ -482,7 +482,7 @@ function StockOperationsRow({
|
|||||||
|
|
||||||
const [statusOpen, statusHandlers] = useDisclosure(false, {
|
const [statusOpen, statusHandlers] = useDisclosure(false, {
|
||||||
onOpen: () => {
|
onOpen: () => {
|
||||||
setStatus(record?.status || undefined);
|
setStatus(record?.status_custom_key || record?.status || undefined);
|
||||||
props.changeFn(props.idx, 'status', record?.status || undefined);
|
props.changeFn(props.idx, 'status', record?.status || undefined);
|
||||||
},
|
},
|
||||||
onClose: () => {
|
onClose: () => {
|
||||||
|
@ -31,14 +31,18 @@ export default function useStatusCodes({
|
|||||||
const statusCodeList = useGlobalStatusState.getState().status;
|
const statusCodeList = useGlobalStatusState.getState().status;
|
||||||
|
|
||||||
const codes = useMemo(() => {
|
const codes = useMemo(() => {
|
||||||
const statusCodes = getStatusCodes(modelType) || {};
|
const statusCodes = getStatusCodes(modelType) || null;
|
||||||
|
|
||||||
const codesMap: Record<any, any> = {};
|
const codesMap: Record<any, any> = {};
|
||||||
|
|
||||||
for (const name in statusCodes) {
|
if (!statusCodes) {
|
||||||
codesMap[name] = statusCodes[name].key;
|
return codesMap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Object.keys(statusCodes.values).forEach((name) => {
|
||||||
|
codesMap[name] = statusCodes.values[name].key;
|
||||||
|
});
|
||||||
|
|
||||||
return codesMap;
|
return codesMap;
|
||||||
}, [modelType, statusCodeList]);
|
}, [modelType, statusCodeList]);
|
||||||
|
|
||||||
|
@ -107,6 +107,15 @@ export default function BuildDetail() {
|
|||||||
label: t`Status`,
|
label: t`Status`,
|
||||||
model: ModelType.build
|
model: ModelType.build
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
type: 'status',
|
||||||
|
name: 'status_custom_key',
|
||||||
|
label: t`Custom Status`,
|
||||||
|
model: ModelType.build,
|
||||||
|
icon: 'status',
|
||||||
|
hidden:
|
||||||
|
!build.status_custom_key || build.status_custom_key == build.status
|
||||||
|
},
|
||||||
{
|
{
|
||||||
type: 'text',
|
type: 'text',
|
||||||
name: 'reference',
|
name: 'reference',
|
||||||
|
@ -144,6 +144,15 @@ export default function PurchaseOrderDetail() {
|
|||||||
name: 'status',
|
name: 'status',
|
||||||
label: t`Status`,
|
label: t`Status`,
|
||||||
model: ModelType.purchaseorder
|
model: ModelType.purchaseorder
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'status',
|
||||||
|
name: 'status_custom_key',
|
||||||
|
label: t`Custom Status`,
|
||||||
|
model: ModelType.purchaseorder,
|
||||||
|
icon: 'status',
|
||||||
|
hidden:
|
||||||
|
!order.status_custom_key || order.status_custom_key == order.status
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
@ -115,6 +115,15 @@ export default function ReturnOrderDetail() {
|
|||||||
name: 'status',
|
name: 'status',
|
||||||
label: t`Status`,
|
label: t`Status`,
|
||||||
model: ModelType.returnorder
|
model: ModelType.returnorder
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'status',
|
||||||
|
name: 'status_custom_key',
|
||||||
|
label: t`Custom Status`,
|
||||||
|
model: ModelType.returnorder,
|
||||||
|
icon: 'status',
|
||||||
|
hidden:
|
||||||
|
!order.status_custom_key || order.status_custom_key == order.status
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
@ -124,6 +124,15 @@ export default function SalesOrderDetail() {
|
|||||||
name: 'status',
|
name: 'status',
|
||||||
label: t`Status`,
|
label: t`Status`,
|
||||||
model: ModelType.salesorder
|
model: ModelType.salesorder
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'status',
|
||||||
|
name: 'status_custom_key',
|
||||||
|
label: t`Custom Status`,
|
||||||
|
model: ModelType.salesorder,
|
||||||
|
icon: 'status',
|
||||||
|
hidden:
|
||||||
|
!order.status_custom_key || order.status_custom_key == order.status
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
@ -133,11 +133,21 @@ export default function StockDetail() {
|
|||||||
icon: 'part',
|
icon: 'part',
|
||||||
hidden: !part.IPN
|
hidden: !part.IPN
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'status',
|
||||||
|
type: 'status',
|
||||||
|
label: t`Status`,
|
||||||
|
model: ModelType.stockitem
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'status_custom_key',
|
name: 'status_custom_key',
|
||||||
type: 'status',
|
type: 'status',
|
||||||
label: t`Stock Status`,
|
label: t`Custom Status`,
|
||||||
model: ModelType.stockitem
|
model: ModelType.stockitem,
|
||||||
|
icon: 'status',
|
||||||
|
hidden:
|
||||||
|
!stockitem.status_custom_key ||
|
||||||
|
stockitem.status_custom_key == stockitem.status
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'text',
|
type: 'text',
|
||||||
@ -845,11 +855,10 @@ export default function StockDetail() {
|
|||||||
key='batch'
|
key='batch'
|
||||||
/>,
|
/>,
|
||||||
<StatusRenderer
|
<StatusRenderer
|
||||||
status={stockitem.status_custom_key}
|
status={stockitem.status_custom_key || stockitem.status}
|
||||||
type={ModelType.stockitem}
|
type={ModelType.stockitem}
|
||||||
options={{
|
options={{
|
||||||
size: 'lg',
|
size: 'lg'
|
||||||
hidden: !!stockitem.status_custom_key
|
|
||||||
}}
|
}}
|
||||||
key='status'
|
key='status'
|
||||||
/>,
|
/>,
|
||||||
|
@ -9,7 +9,7 @@ import type { ModelType } from '../enums/ModelType';
|
|||||||
import { apiUrl } from './ApiState';
|
import { apiUrl } from './ApiState';
|
||||||
import { useUserState } from './UserState';
|
import { useUserState } from './UserState';
|
||||||
|
|
||||||
type StatusLookup = Record<ModelType | string, StatusCodeListInterface>;
|
export type StatusLookup = Record<ModelType | string, StatusCodeListInterface>;
|
||||||
|
|
||||||
interface ServerStateProps {
|
interface ServerStateProps {
|
||||||
status?: StatusLookup;
|
status?: StatusLookup;
|
||||||
@ -35,8 +35,10 @@ export const useGlobalStatusState = create<ServerStateProps>()(
|
|||||||
.then((response) => {
|
.then((response) => {
|
||||||
const newStatusLookup: StatusLookup = {} as StatusLookup;
|
const newStatusLookup: StatusLookup = {} as StatusLookup;
|
||||||
for (const key in response.data) {
|
for (const key in response.data) {
|
||||||
newStatusLookup[statusCodeList[key] || key] =
|
newStatusLookup[statusCodeList[key] || key] = {
|
||||||
response.data[key].values;
|
status_class: key,
|
||||||
|
values: response.data[key].values
|
||||||
|
};
|
||||||
}
|
}
|
||||||
set({ status: newStatusLookup });
|
set({ status: newStatusLookup });
|
||||||
})
|
})
|
||||||
|
@ -1,7 +1,11 @@
|
|||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
StatusCodeInterface,
|
||||||
|
StatusCodeListInterface
|
||||||
|
} from '../components/render/StatusRenderer';
|
||||||
import type { ModelType } from '../enums/ModelType';
|
import type { ModelType } from '../enums/ModelType';
|
||||||
import { useGlobalStatusState } from '../states/StatusState';
|
import { type StatusLookup, useGlobalStatusState } from '../states/StatusState';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Interface for the table filter choice
|
* Interface for the table filter choice
|
||||||
@ -71,17 +75,18 @@ export function StatusFilterOptions(
|
|||||||
model: ModelType
|
model: ModelType
|
||||||
): () => TableFilterChoice[] {
|
): () => TableFilterChoice[] {
|
||||||
return () => {
|
return () => {
|
||||||
const statusCodeList = useGlobalStatusState.getState().status;
|
const statusCodeList: StatusLookup | undefined =
|
||||||
|
useGlobalStatusState.getState().status;
|
||||||
|
|
||||||
if (!statusCodeList) {
|
if (!statusCodeList) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const codes = statusCodeList[model];
|
const codes: StatusCodeListInterface | undefined = statusCodeList[model];
|
||||||
|
|
||||||
if (codes) {
|
if (codes) {
|
||||||
return Object.keys(codes).map((key) => {
|
return Object.keys(codes.values).map((key) => {
|
||||||
const entry = codes[key];
|
const entry: StatusCodeInterface = codes.values[key];
|
||||||
return {
|
return {
|
||||||
value: entry.key.toString(),
|
value: entry.key.toString(),
|
||||||
label: entry.label?.toString() ?? entry.key.toString()
|
label: entry.label?.toString() ?? entry.key.toString()
|
||||||
|
@ -1,10 +1,16 @@
|
|||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
|
import { Badge } from '@mantine/core';
|
||||||
import { useCallback, useMemo, useState } from 'react';
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { AddItemButton } from '../../components/buttons/AddItemButton';
|
import { AddItemButton } from '../../components/buttons/AddItemButton';
|
||||||
|
import type {
|
||||||
|
StatusCodeInterface,
|
||||||
|
StatusCodeListInterface
|
||||||
|
} from '../../components/render/StatusRenderer';
|
||||||
|
import { statusColorMap } from '../../defaults/backendMappings';
|
||||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||||
import { UserRoles } from '../../enums/Roles';
|
import { UserRoles } from '../../enums/Roles';
|
||||||
import { customStateFields } from '../../forms/CommonForms';
|
import { useCustomStateFields } from '../../forms/CommonForms';
|
||||||
import {
|
import {
|
||||||
useCreateApiFormModal,
|
useCreateApiFormModal,
|
||||||
useDeleteApiFormModal,
|
useDeleteApiFormModal,
|
||||||
@ -12,10 +18,17 @@ import {
|
|||||||
} from '../../hooks/UseForm';
|
} from '../../hooks/UseForm';
|
||||||
import { useTable } from '../../hooks/UseTable';
|
import { useTable } from '../../hooks/UseTable';
|
||||||
import { apiUrl } from '../../states/ApiState';
|
import { apiUrl } from '../../states/ApiState';
|
||||||
|
import { useGlobalStatusState } from '../../states/StatusState';
|
||||||
import { useUserState } from '../../states/UserState';
|
import { useUserState } from '../../states/UserState';
|
||||||
import type { TableColumn } from '../Column';
|
import type { TableColumn } from '../Column';
|
||||||
|
import type { TableFilter } from '../Filter';
|
||||||
import { InvenTreeTable } from '../InvenTreeTable';
|
import { InvenTreeTable } from '../InvenTreeTable';
|
||||||
import { type RowAction, RowDeleteAction, RowEditAction } from '../RowActions';
|
import {
|
||||||
|
type RowAction,
|
||||||
|
RowDeleteAction,
|
||||||
|
RowDuplicateAction,
|
||||||
|
RowEditAction
|
||||||
|
} from '../RowActions';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Table for displaying list of custom states
|
* Table for displaying list of custom states
|
||||||
@ -23,12 +36,64 @@ import { type RowAction, RowDeleteAction, RowEditAction } from '../RowActions';
|
|||||||
export default function CustomStateTable() {
|
export default function CustomStateTable() {
|
||||||
const table = useTable('customstates');
|
const table = useTable('customstates');
|
||||||
|
|
||||||
|
const statusCodes = useGlobalStatusState();
|
||||||
|
|
||||||
|
// Find the associated logical state key
|
||||||
|
const getLogicalState = useCallback(
|
||||||
|
(group: string, key: number) => {
|
||||||
|
const valuesList = Object.values(statusCodes.status ?? {}).find(
|
||||||
|
(value: StatusCodeListInterface) => value.status_class === group
|
||||||
|
);
|
||||||
|
|
||||||
|
const value = Object.values(valuesList?.values ?? {}).find(
|
||||||
|
(value: StatusCodeInterface) => value.key === key
|
||||||
|
);
|
||||||
|
|
||||||
|
return value?.label ?? value?.name ?? '';
|
||||||
|
},
|
||||||
|
[statusCodes]
|
||||||
|
);
|
||||||
|
|
||||||
const user = useUserState();
|
const user = useUserState();
|
||||||
|
|
||||||
|
const tableFilters: TableFilter[] = useMemo(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name: 'reference_status',
|
||||||
|
label: t`Status Group`,
|
||||||
|
field_type: 'choice',
|
||||||
|
choices: Object.values(statusCodes.status ?? {}).map(
|
||||||
|
(value: StatusCodeListInterface) => ({
|
||||||
|
label: value.status_class,
|
||||||
|
value: value.status_class
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}, [statusCodes]);
|
||||||
|
|
||||||
const columns: TableColumn[] = useMemo(() => {
|
const columns: TableColumn[] = useMemo(() => {
|
||||||
return [
|
return [
|
||||||
|
{
|
||||||
|
accessor: 'reference_status',
|
||||||
|
title: t`Status`,
|
||||||
|
sortable: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessor: 'logical_key',
|
||||||
|
title: t`Logical State`,
|
||||||
|
sortable: true,
|
||||||
|
render: (record: any) => {
|
||||||
|
const stateText = getLogicalState(
|
||||||
|
record.reference_status,
|
||||||
|
record.logical_key
|
||||||
|
);
|
||||||
|
return stateText ? stateText : record.logical_key;
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
accessor: 'name',
|
accessor: 'name',
|
||||||
|
title: t`Identifier`,
|
||||||
sortable: true
|
sortable: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -36,34 +101,45 @@ export default function CustomStateTable() {
|
|||||||
title: t`Display Name`,
|
title: t`Display Name`,
|
||||||
sortable: true
|
sortable: true
|
||||||
},
|
},
|
||||||
{
|
|
||||||
accessor: 'color'
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
accessor: 'key',
|
accessor: 'key',
|
||||||
sortable: true
|
sortable: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessor: 'logical_key',
|
accessor: 'color',
|
||||||
sortable: true
|
render: (record: any) => {
|
||||||
},
|
return (
|
||||||
{
|
<Badge
|
||||||
accessor: 'model_name',
|
color={statusColorMap[record.color] || statusColorMap['default']}
|
||||||
title: t`Model`,
|
variant='filled'
|
||||||
sortable: true
|
size='xs'
|
||||||
},
|
>
|
||||||
{
|
{record.color}
|
||||||
accessor: 'reference_status',
|
</Badge>
|
||||||
title: t`Status`,
|
);
|
||||||
sortable: true
|
}
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
}, []);
|
}, [getLogicalState]);
|
||||||
|
|
||||||
|
const newCustomStateFields = useCustomStateFields();
|
||||||
|
const duplicateCustomStateFields = useCustomStateFields();
|
||||||
|
const editCustomStateFields = useCustomStateFields();
|
||||||
|
|
||||||
|
const [initialStateData, setInitialStateData] = useState<any>({});
|
||||||
|
|
||||||
const newCustomState = useCreateApiFormModal({
|
const newCustomState = useCreateApiFormModal({
|
||||||
url: ApiEndpoints.custom_state_list,
|
url: ApiEndpoints.custom_state_list,
|
||||||
title: t`Add State`,
|
title: t`Add State`,
|
||||||
fields: customStateFields(),
|
fields: newCustomStateFields,
|
||||||
|
table: table
|
||||||
|
});
|
||||||
|
|
||||||
|
const duplicateCustomState = useCreateApiFormModal({
|
||||||
|
url: ApiEndpoints.custom_state_list,
|
||||||
|
title: t`Add State`,
|
||||||
|
fields: duplicateCustomStateFields,
|
||||||
|
initialData: initialStateData,
|
||||||
table: table
|
table: table
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -75,7 +151,7 @@ export default function CustomStateTable() {
|
|||||||
url: ApiEndpoints.custom_state_list,
|
url: ApiEndpoints.custom_state_list,
|
||||||
pk: selectedCustomState,
|
pk: selectedCustomState,
|
||||||
title: t`Edit State`,
|
title: t`Edit State`,
|
||||||
fields: customStateFields(),
|
fields: editCustomStateFields,
|
||||||
table: table
|
table: table
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -96,6 +172,13 @@ export default function CustomStateTable() {
|
|||||||
editCustomState.open();
|
editCustomState.open();
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
RowDuplicateAction({
|
||||||
|
hidden: !user.hasAddRole(UserRoles.admin),
|
||||||
|
onClick: () => {
|
||||||
|
setInitialStateData(record);
|
||||||
|
duplicateCustomState.open();
|
||||||
|
}
|
||||||
|
}),
|
||||||
RowDeleteAction({
|
RowDeleteAction({
|
||||||
hidden: !user.hasDeleteRole(UserRoles.admin),
|
hidden: !user.hasDeleteRole(UserRoles.admin),
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
@ -112,7 +195,10 @@ export default function CustomStateTable() {
|
|||||||
return [
|
return [
|
||||||
<AddItemButton
|
<AddItemButton
|
||||||
key={'add'}
|
key={'add'}
|
||||||
onClick={() => newCustomState.open()}
|
onClick={() => {
|
||||||
|
setInitialStateData({});
|
||||||
|
newCustomState.open();
|
||||||
|
}}
|
||||||
tooltip={t`Add State`}
|
tooltip={t`Add State`}
|
||||||
/>
|
/>
|
||||||
];
|
];
|
||||||
@ -122,6 +208,7 @@ export default function CustomStateTable() {
|
|||||||
<>
|
<>
|
||||||
{newCustomState.modal}
|
{newCustomState.modal}
|
||||||
{editCustomState.modal}
|
{editCustomState.modal}
|
||||||
|
{duplicateCustomState.modal}
|
||||||
{deleteCustomState.modal}
|
{deleteCustomState.modal}
|
||||||
<InvenTreeTable
|
<InvenTreeTable
|
||||||
url={apiUrl(ApiEndpoints.custom_state_list)}
|
url={apiUrl(ApiEndpoints.custom_state_list)}
|
||||||
@ -130,6 +217,7 @@ export default function CustomStateTable() {
|
|||||||
props={{
|
props={{
|
||||||
rowActions: rowActions,
|
rowActions: rowActions,
|
||||||
tableActions: tableActions,
|
tableActions: tableActions,
|
||||||
|
tableFilters: tableFilters,
|
||||||
enableDownload: true
|
enableDownload: true
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
@ -34,7 +34,7 @@ export const clickButtonIfVisible = async (page, name, timeout = 500) => {
|
|||||||
export const clearTableFilters = async (page) => {
|
export const clearTableFilters = async (page) => {
|
||||||
await openFilterDrawer(page);
|
await openFilterDrawer(page);
|
||||||
await clickButtonIfVisible(page, 'Clear Filters');
|
await clickButtonIfVisible(page, 'Clear Filters');
|
||||||
await page.getByLabel('filter-drawer-close').click();
|
await closeFilterDrawer(page);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const setTableChoiceFilter = async (page, filter, value) => {
|
export const setTableChoiceFilter = async (page, filter, value) => {
|
||||||
@ -42,7 +42,9 @@ export const setTableChoiceFilter = async (page, filter, value) => {
|
|||||||
|
|
||||||
await page.getByRole('button', { name: 'Add Filter' }).click();
|
await page.getByRole('button', { name: 'Add Filter' }).click();
|
||||||
await page.getByPlaceholder('Select filter').fill(filter);
|
await page.getByPlaceholder('Select filter').fill(filter);
|
||||||
await page.getByRole('option', { name: 'Status' }).click();
|
await page.getByPlaceholder('Select filter').click();
|
||||||
|
await page.getByRole('option', { name: filter }).click();
|
||||||
|
|
||||||
await page.getByPlaceholder('Select filter value').click();
|
await page.getByPlaceholder('Select filter value').click();
|
||||||
await page.getByRole('option', { name: value }).click();
|
await page.getByRole('option', { name: value }).click();
|
||||||
|
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
import { test } from '../baseFixtures.ts';
|
import { test } from '../baseFixtures.ts';
|
||||||
import { baseUrl } from '../defaults.ts';
|
import { baseUrl } from '../defaults.ts';
|
||||||
import {
|
import {
|
||||||
clickButtonIfVisible,
|
clearTableFilters,
|
||||||
getRowFromCell,
|
getRowFromCell,
|
||||||
openFilterDrawer
|
setTableChoiceFilter
|
||||||
} from '../helpers.ts';
|
} from '../helpers.ts';
|
||||||
import { doQuickLogin } from '../login.ts';
|
import { doQuickLogin } from '../login.ts';
|
||||||
|
|
||||||
@ -266,6 +266,24 @@ test('Build Order - Filters', async ({ page }) => {
|
|||||||
|
|
||||||
await page.goto(`${baseUrl}/manufacturing/index/buildorders`);
|
await page.goto(`${baseUrl}/manufacturing/index/buildorders`);
|
||||||
|
|
||||||
await openFilterDrawer(page);
|
await clearTableFilters(page);
|
||||||
await clickButtonIfVisible(page, 'Clear Filters');
|
await page.getByText('1 - 24 / 24').waitFor();
|
||||||
|
|
||||||
|
// Toggle 'Outstanding' filter
|
||||||
|
await setTableChoiceFilter(page, 'Outstanding', 'Yes');
|
||||||
|
await page.getByText('1 - 18 / 18').waitFor();
|
||||||
|
await clearTableFilters(page);
|
||||||
|
await setTableChoiceFilter(page, 'Outstanding', 'No');
|
||||||
|
await page.getByText('1 - 6 / 6').waitFor();
|
||||||
|
await clearTableFilters(page);
|
||||||
|
|
||||||
|
// Filter by custom status code
|
||||||
|
await setTableChoiceFilter(page, 'Status', 'Pending Approval');
|
||||||
|
|
||||||
|
// Single result - navigate through to the build order
|
||||||
|
await page.getByText('1 - 1 / 1').waitFor();
|
||||||
|
await page.getByRole('cell', { name: 'BO0023' }).click();
|
||||||
|
|
||||||
|
await page.getByText('On Hold').first().waitFor();
|
||||||
|
await page.getByText('Pending Approval').first().waitFor();
|
||||||
});
|
});
|
||||||
|
@ -1,6 +1,11 @@
|
|||||||
import { test } from '../baseFixtures.js';
|
import { test } from '../baseFixtures.js';
|
||||||
import { baseUrl } from '../defaults.js';
|
import { baseUrl } from '../defaults.js';
|
||||||
import { clickButtonIfVisible, openFilterDrawer } from '../helpers.js';
|
import {
|
||||||
|
clearTableFilters,
|
||||||
|
clickButtonIfVisible,
|
||||||
|
openFilterDrawer,
|
||||||
|
setTableChoiceFilter
|
||||||
|
} from '../helpers.js';
|
||||||
import { doQuickLogin } from '../login.js';
|
import { doQuickLogin } from '../login.js';
|
||||||
|
|
||||||
test('Stock - Basic Tests', async ({ page }) => {
|
test('Stock - Basic Tests', async ({ page }) => {
|
||||||
@ -84,9 +89,15 @@ test('Stock - Filters', async ({ page }) => {
|
|||||||
.getByRole('cell', { name: 'A round table - with blue paint' })
|
.getByRole('cell', { name: 'A round table - with blue paint' })
|
||||||
.waitFor();
|
.waitFor();
|
||||||
|
|
||||||
// Clear filters (ready for next set of tests)
|
// Filter by custom status code
|
||||||
await openFilterDrawer(page);
|
await clearTableFilters(page);
|
||||||
await clickButtonIfVisible(page, 'Clear Filters');
|
await setTableChoiceFilter(page, 'Status', 'Incoming goods inspection');
|
||||||
|
await page.getByText('1 - 8 / 8').waitFor();
|
||||||
|
await page.getByRole('cell', { name: '1551AGY' }).first().waitFor();
|
||||||
|
await page.getByRole('cell', { name: 'widget.blue' }).first().waitFor();
|
||||||
|
await page.getByRole('cell', { name: '002.01-PCBA' }).first().waitFor();
|
||||||
|
|
||||||
|
await clearTableFilters(page);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Stock - Serial Numbers', async ({ page }) => {
|
test('Stock - Serial Numbers', async ({ page }) => {
|
||||||
@ -158,47 +169,58 @@ test('Stock - Serial Numbers', async ({ page }) => {
|
|||||||
test('Stock - Stock Actions', async ({ page }) => {
|
test('Stock - Stock Actions', async ({ page }) => {
|
||||||
await doQuickLogin(page);
|
await doQuickLogin(page);
|
||||||
|
|
||||||
// Find an in-stock, untracked item
|
await page.goto(`${baseUrl}/stock/item/1225/details`);
|
||||||
await page.goto(
|
|
||||||
`${baseUrl}/stock/location/index/stock-items?in_stock=1&serialized=0`
|
// Helper function to launch a stock action
|
||||||
);
|
const launchStockAction = async (action: string) => {
|
||||||
await page.getByText('530470210').first().click();
|
await page.getByLabel('action-menu-stock-operations').click();
|
||||||
|
await page.getByLabel(`action-menu-stock-operations-${action}`).click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const setStockStatus = async (status: string) => {
|
||||||
|
await page.getByLabel('action-button-change-status').click();
|
||||||
|
await page.getByLabel('choice-field-status').click();
|
||||||
|
await page.getByRole('option', { name: status }).click();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check for required values
|
||||||
|
await page.getByText('Status', { exact: true }).waitFor();
|
||||||
|
await page.getByText('Custom Status', { exact: true }).waitFor();
|
||||||
|
await page.getByText('Attention needed').waitFor();
|
||||||
await page
|
await page
|
||||||
.locator('div')
|
.getByLabel('Stock Details')
|
||||||
.filter({ hasText: /^Quantity: 270$/ })
|
.getByText('Incoming goods inspection')
|
||||||
.first()
|
|
||||||
.waitFor();
|
.waitFor();
|
||||||
|
await page.getByText('123').first().waitFor();
|
||||||
|
|
||||||
// Check for expected action sections
|
// Add stock, and change status
|
||||||
await page.getByLabel('action-menu-barcode-actions').click();
|
await launchStockAction('add');
|
||||||
await page.getByLabel('action-menu-barcode-actions-link-barcode').click();
|
await page.getByLabel('number-field-quantity').fill('12');
|
||||||
await page.getByRole('banner').getByRole('button').click();
|
await setStockStatus('Lost');
|
||||||
|
|
||||||
await page.getByLabel('action-menu-printing-actions').click();
|
|
||||||
await page.getByLabel('action-menu-printing-actions-print-labels').click();
|
|
||||||
await page.getByRole('button', { name: 'Cancel' }).click();
|
|
||||||
|
|
||||||
await page.getByLabel('action-menu-stock-operations').click();
|
|
||||||
await page.getByLabel('action-menu-stock-operations-count').waitFor();
|
|
||||||
await page.getByLabel('action-menu-stock-operations-add').waitFor();
|
|
||||||
await page.getByLabel('action-menu-stock-operations-remove').waitFor();
|
|
||||||
|
|
||||||
await page.getByLabel('action-menu-stock-operations-transfer').click();
|
|
||||||
await page.getByLabel('text-field-notes').fill('test notes');
|
|
||||||
await page.getByRole('button', { name: 'Submit' }).click();
|
await page.getByRole('button', { name: 'Submit' }).click();
|
||||||
await page.getByText('This field is required.').first().waitFor();
|
|
||||||
|
|
||||||
// Set the status field
|
await page.getByText('Lost').first().waitFor();
|
||||||
await page.getByLabel('action-button-change-status').click();
|
await page.getByText('Unavailable').first().waitFor();
|
||||||
await page.getByLabel('choice-field-status').click();
|
await page.getByText('135').first().waitFor();
|
||||||
await page.getByText('Attention needed').click();
|
|
||||||
|
|
||||||
// Set the packaging field
|
// Remove stock, and change status
|
||||||
await page.getByLabel('action-button-adjust-packaging').click();
|
await launchStockAction('remove');
|
||||||
await page.getByLabel('text-field-packaging').fill('test packaging');
|
await page.getByLabel('number-field-quantity').fill('99');
|
||||||
|
await setStockStatus('Damaged');
|
||||||
|
await page.getByRole('button', { name: 'Submit' }).click();
|
||||||
|
|
||||||
// Close the dialog
|
await page.getByText('36').first().waitFor();
|
||||||
await page.getByRole('button', { name: 'Cancel' }).click();
|
await page.getByText('Damaged').first().waitFor();
|
||||||
|
|
||||||
|
// Count stock and change status (reverting to original value)
|
||||||
|
await launchStockAction('count');
|
||||||
|
await page.getByLabel('number-field-quantity').fill('123');
|
||||||
|
await setStockStatus('Incoming goods inspection');
|
||||||
|
await page.getByRole('button', { name: 'Submit' }).click();
|
||||||
|
|
||||||
|
await page.getByText('123').first().waitFor();
|
||||||
|
await page.getByText('Custom Status').first().waitFor();
|
||||||
|
await page.getByText('Incoming goods inspection').first().waitFor();
|
||||||
|
|
||||||
// Find an item which has been sent to a customer
|
// Find an item which has been sent to a customer
|
||||||
await page.goto(`${baseUrl}/stock/item/1014/details`);
|
await page.goto(`${baseUrl}/stock/item/1014/details`);
|
||||||
@ -220,7 +242,4 @@ test('Stock - Tracking', async ({ page }) => {
|
|||||||
await page.getByText('- - Factory/Office Block/Room').first().waitFor();
|
await page.getByText('- - Factory/Office Block/Room').first().waitFor();
|
||||||
await page.getByRole('link', { name: 'Widget Assembly' }).waitFor();
|
await page.getByRole('link', { name: 'Widget Assembly' }).waitFor();
|
||||||
await page.getByRole('cell', { name: 'Installed into assembly' }).waitFor();
|
await page.getByRole('cell', { name: 'Installed into assembly' }).waitFor();
|
||||||
|
|
||||||
await page.waitForTimeout(1500);
|
|
||||||
return;
|
|
||||||
});
|
});
|
||||||
|
@ -153,7 +153,7 @@ test('Settings - Admin - Barcode History', async ({ page, request }) => {
|
|||||||
await page.getByRole('menuitem', { name: 'Admin Center' }).click();
|
await page.getByRole('menuitem', { name: 'Admin Center' }).click();
|
||||||
await page.getByRole('tab', { name: 'Barcode Scans' }).click();
|
await page.getByRole('tab', { name: 'Barcode Scans' }).click();
|
||||||
|
|
||||||
await page.waitForTimeout(2000);
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
// Barcode history is displayed in table
|
// Barcode history is displayed in table
|
||||||
barcodes.forEach(async (barcode) => {
|
barcodes.forEach(async (barcode) => {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user