From 964984ccacb3024361ee2b444ead141ba09f4dd9 Mon Sep 17 00:00:00 2001 From: Oliver Date: Sun, 29 Dec 2024 08:45:23 +1100 Subject: [PATCH] [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 --- .../InvenTree/InvenTree/api_version.py | 5 +- src/backend/InvenTree/build/api.py | 12 +- ...ld_status_custom_key_alter_build_status.py | 13 +- src/backend/InvenTree/build/models.py | 6 +- src/backend/InvenTree/build/serializers.py | 4 +- ...userstatemodel_unique_together_and_more.py | 32 ++++ src/backend/InvenTree/common/models.py | 112 ++++++++----- src/backend/InvenTree/common/serializers.py | 1 + src/backend/InvenTree/common/validators.py | 14 ++ .../InvenTree/generic/states/__init__.py | 3 +- src/backend/InvenTree/generic/states/api.py | 58 ++++++- .../InvenTree/generic/states/custom.py | 65 +------- .../InvenTree/generic/states/fields.py | 27 +++ .../InvenTree/generic/states/serializers.py | 41 +++++ .../InvenTree/generic/states/states.py | 156 ++++++++++++++++-- src/backend/InvenTree/generic/states/tests.py | 17 +- .../InvenTree/generic/states/validators.py | 21 +++ src/backend/InvenTree/order/api.py | 10 +- ...urchaseorder_status_custom_key_and_more.py | 41 +++++ src/backend/InvenTree/order/models.py | 15 +- src/backend/InvenTree/order/serializers.py | 6 +- src/backend/InvenTree/stock/api.py | 10 +- ...13_stockitem_status_custom_key_and_more.py | 13 +- src/backend/InvenTree/stock/models.py | 43 ++++- src/backend/InvenTree/stock/serializers.py | 10 +- .../src/components/render/StatusRenderer.tsx | 25 +-- src/frontend/src/defaults/backendMappings.tsx | 2 +- src/frontend/src/forms/CommonForms.tsx | 61 +++++-- src/frontend/src/forms/StockForms.tsx | 2 +- src/frontend/src/hooks/UseStatusCodes.tsx | 10 +- src/frontend/src/pages/build/BuildDetail.tsx | 9 + .../pages/purchasing/PurchaseOrderDetail.tsx | 9 + .../src/pages/sales/ReturnOrderDetail.tsx | 9 + .../src/pages/sales/SalesOrderDetail.tsx | 9 + src/frontend/src/pages/stock/StockDetail.tsx | 19 ++- src/frontend/src/states/StatusState.tsx | 8 +- src/frontend/src/tables/Filter.tsx | 15 +- .../src/tables/settings/CustomStateTable.tsx | 130 ++++++++++++--- src/frontend/tests/helpers.ts | 6 +- src/frontend/tests/pages/pui_build.spec.ts | 26 ++- src/frontend/tests/pages/pui_stock.spec.ts | 101 +++++++----- src/frontend/tests/pui_settings.spec.ts | 2 +- 42 files changed, 916 insertions(+), 262 deletions(-) create mode 100644 src/backend/InvenTree/common/migrations/0034_alter_inventreecustomuserstatemodel_unique_together_and_more.py create mode 100644 src/backend/InvenTree/generic/states/serializers.py create mode 100644 src/backend/InvenTree/generic/states/validators.py diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index bd03cc9a31..14c4f62e5b 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,13 +1,16 @@ """InvenTree API version information.""" # 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.""" 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 - Adjust default "part_detail" behaviour for StockItem API endpoints diff --git a/src/backend/InvenTree/build/api.py b/src/backend/InvenTree/build/api.py index 8623bd8e8c..9003100ade 100644 --- a/src/backend/InvenTree/build/api.py +++ b/src/backend/InvenTree/build/api.py @@ -34,7 +34,17 @@ class BuildFilter(rest_filters.FilterSet): model = Build 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') diff --git a/src/backend/InvenTree/build/migrations/0052_build_status_custom_key_alter_build_status.py b/src/backend/InvenTree/build/migrations/0052_build_status_custom_key_alter_build_status.py index 9e0776fd09..3f3f77619f 100644 --- a/src/backend/InvenTree/build/migrations/0052_build_status_custom_key_alter_build_status.py +++ b/src/backend/InvenTree/build/migrations/0052_build_status_custom_key_alter_build_status.py @@ -4,6 +4,7 @@ import django.core.validators from django.db import migrations import generic.states.fields +import generic.states.validators import InvenTree.status_codes @@ -23,6 +24,11 @@ class Migration(migrations.Migration): help_text="Additional status information for this item", null=True, verbose_name="Custom status key", + validators=[ + generic.states.validators.CustomStatusCodeValidator( + status_class=InvenTree.status_codes.BuildStatus + ), + ] ), ), migrations.AlterField( @@ -32,7 +38,12 @@ class Migration(migrations.Migration): choices=InvenTree.status_codes.BuildStatus.items(), default=10, 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", ), ), diff --git a/src/backend/InvenTree/build/models.py b/src/backend/InvenTree/build/models.py index 236cfbdb85..7de5cc6130 100644 --- a/src/backend/InvenTree/build/models.py +++ b/src/backend/InvenTree/build/models.py @@ -43,7 +43,7 @@ from common.settings import ( get_global_setting, 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 stock.status_codes import StockHistoryCode, StockStatus @@ -59,6 +59,7 @@ class Build( InvenTree.models.PluginValidationMixin, InvenTree.models.ReferenceIndexingMixin, StateTransitionMixin, + StatusCodeMixin, MPTTModel, ): """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 """ + STATUS_CLASS = BuildStatus + class Meta: """Metaclass options for the BuildOrder model.""" @@ -319,6 +322,7 @@ class Build( verbose_name=_('Build Status'), default=BuildStatus.PENDING.value, choices=BuildStatus.items(), + status_class=BuildStatus, validators=[MinValueValidator(0)], help_text=_('Build status code'), ) diff --git a/src/backend/InvenTree/build/serializers.py b/src/backend/InvenTree/build/serializers.py index 1e10c0db96..2352bf3f54 100644 --- a/src/backend/InvenTree/build/serializers.py +++ b/src/backend/InvenTree/build/serializers.py @@ -574,7 +574,9 @@ class BuildOutputCompleteSerializer(serializers.Serializer): ) 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( diff --git a/src/backend/InvenTree/common/migrations/0034_alter_inventreecustomuserstatemodel_unique_together_and_more.py b/src/backend/InvenTree/common/migrations/0034_alter_inventreecustomuserstatemodel_unique_together_and_more.py new file mode 100644 index 0000000000..b2eae1367a --- /dev/null +++ b/src/backend/InvenTree/common/migrations/0034_alter_inventreecustomuserstatemodel_unique_together_and_more.py @@ -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')}, + ), + ] diff --git a/src/backend/InvenTree/common/models.py b/src/backend/InvenTree/common/models.py index ff74118833..163fb77241 100644 --- a/src/backend/InvenTree/common/models.py +++ b/src/backend/InvenTree/common/models.py @@ -51,7 +51,7 @@ import InvenTree.validators import users.models from common.setting.type import InvenTreeSettingsKeyType, SettingsKeyType 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 logger = logging.getLogger('inventree') @@ -1927,20 +1927,59 @@ class Attachment(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel 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( - verbose_name=_('Key'), - help_text=_('Value that will be saved in the models database'), + verbose_name=_('Value'), + help_text=_('Numerical value that will be saved in the models database'), ) + 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( max_length=250, verbose_name=_('Label'), help_text=_('Label that will be displayed in the frontend'), ) + color = models.CharField( max_length=10, choices=state_color_mappings(), @@ -1948,12 +1987,7 @@ class InvenTreeCustomUserStateModel(models.Model): verbose_name=_('Color'), 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( ContentType, on_delete=models.SET_NULL, @@ -1962,18 +1996,6 @@ class InvenTreeCustomUserStateModel(models.Model): verbose_name=_('Model'), 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: """Return string representation of the custom state.""" @@ -1999,38 +2021,50 @@ class InvenTreeCustomUserStateModel(models.Model): if self.key == self.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({ - '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 - 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 + if self.key in status_class.values(): raise ValidationError({ 'key': _( '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({ 'logical_key': _( '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() + 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 which represents a list of selectable items for parameters. diff --git a/src/backend/InvenTree/common/serializers.py b/src/backend/InvenTree/common/serializers.py index aa6240d8d2..780f99893d 100644 --- a/src/backend/InvenTree/common/serializers.py +++ b/src/backend/InvenTree/common/serializers.py @@ -355,6 +355,7 @@ class CustomStateSerializer(DataImportExportSerializerMixin, InvenTreeModelSeria ] model_name = serializers.CharField(read_only=True, source='model.name') + reference_status = serializers.ChoiceField( choices=generic.states.custom.state_reference_mappings() ) diff --git a/src/backend/InvenTree/common/validators.py b/src/backend/InvenTree/common/validators.py index efb82e692e..02f8f7781a 100644 --- a/src/backend/InvenTree/common/validators.py +++ b/src/backend/InvenTree/common/validators.py @@ -113,3 +113,17 @@ def validate_icon(name: Union[str, None]): return 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')) diff --git a/src/backend/InvenTree/generic/states/__init__.py b/src/backend/InvenTree/generic/states/__init__.py index 0389499907..90922e9695 100644 --- a/src/backend/InvenTree/generic/states/__init__.py +++ b/src/backend/InvenTree/generic/states/__init__.py @@ -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. """ -from .states import ColorEnum, StatusCode +from .states import ColorEnum, StatusCode, StatusCodeMixin from .transition import StateTransitionMixin, TransitionMethod, storage __all__ = [ 'ColorEnum', 'StateTransitionMixin', 'StatusCode', + 'StatusCodeMixin', 'TransitionMethod', 'storage', ] diff --git a/src/backend/InvenTree/generic/states/api.py b/src/backend/InvenTree/generic/states/api.py index 49c7425335..092f5bbb41 100644 --- a/src/backend/InvenTree/generic/states/api.py +++ b/src/backend/InvenTree/generic/states/api.py @@ -11,14 +11,13 @@ from rest_framework.response import Response import common.models import common.serializers -from generic.states.custom import get_status_api_response from importer.mixins import DataExportViewMixin from InvenTree.filters import SEARCH_ORDER_FILTER from InvenTree.mixins import ListCreateAPI, RetrieveUpdateDestroyAPI from InvenTree.permissions import IsStaffOrReadOnly from InvenTree.serializers import EmptySerializer -from machine.machine_type import MachineStatus +from .serializers import GenericStateClassSerializer from .states import StatusCode @@ -38,6 +37,7 @@ class StatusView(GenericAPIView): """ permission_classes = [permissions.IsAuthenticated] + serializer_class = GenericStateClassSerializer # Override status_class for implementing subclass MODEL_REF = 'statusmodel' @@ -56,7 +56,7 @@ class StatusView(GenericAPIView): @extend_schema( description='Retrieve information about a specific status code', responses={ - 200: OpenApiResponse(description='Status code information'), + 200: GenericStateClassSerializer, 400: OpenApiResponse(description='Invalid request'), }, ) @@ -70,9 +70,27 @@ class StatusView(GenericAPIView): if not issubclass(status_class, StatusCode): 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): @@ -83,9 +101,32 @@ class AllStatusViews(StatusView): def get(self, request, *args, **kwargs): """Perform a GET request to learn information about status codes.""" - data = get_status_api_response() - # Extend with MachineStatus classes - data.update(get_status_api_response(MachineStatus, prefix=['MachineStatus'])) + from InvenTree.helpers import inheritors + + 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) @@ -99,6 +140,7 @@ class CustomStateList(DataExportViewMixin, ListCreateAPI): filter_backends = SEARCH_ORDER_FILTER ordering_fields = ['key'] search_fields = ['key', 'name', 'label', 'reference_status'] + filterset_fields = ['model', 'reference_status'] class CustomStateDetail(RetrieveUpdateDestroyAPI): diff --git a/src/backend/InvenTree/generic/states/custom.py b/src/backend/InvenTree/generic/states/custom.py index 8fc8948c28..da1dbe743f 100644 --- a/src/backend/InvenTree/generic/states/custom.py +++ b/src/backend/InvenTree/generic/states/custom.py @@ -7,23 +7,7 @@ from .states import ColorEnum, StatusCode def get_custom_status_labels(include_custom: bool = True): """Return a dict of custom status labels.""" - return {cls.tag(): cls for cls in get_custom_classes(include_custom)} - - -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) - } + return {cls.tag(): cls for cls in inheritors(StatusCode)} def state_color_mappings(): @@ -33,7 +17,7 @@ def state_color_mappings(): def state_reference_mappings(): """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__)] @@ -42,48 +26,3 @@ def get_logical_value(value, model: str): from common.models import InvenTreeCustomUserStateModel 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() diff --git a/src/backend/InvenTree/generic/states/fields.py b/src/backend/InvenTree/generic/states/fields.py index 7f67f5c14b..19d23b028e 100644 --- a/src/backend/InvenTree/generic/states/fields.py +++ b/src/backend/InvenTree/generic/states/fields.py @@ -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. """ + 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): """Deconstruct the field for migrations.""" name, path, args, kwargs = super().deconstruct() @@ -109,14 +123,23 @@ class InvenTreeCustomStatusModelField(models.PositiveIntegerField): """Ensure that the value is not an empty string.""" if value == '': value = None + return super().clean(value, model_instance) def add_field(self, cls, name): """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( default=None, verbose_name=_('Custom status key'), help_text=_('Additional status information for this item'), + validators=validators, blank=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. """ + def __init__(self, *args, **kwargs): + """Initialize the field.""" + super().__init__(*args, **kwargs) + class InvenTreeCustomStatusSerializerMixin: """Mixin to ensure custom status fields are set. diff --git a/src/backend/InvenTree/generic/states/serializers.py b/src/backend/InvenTree/generic/states/serializers.py new file mode 100644 index 0000000000..52464dfaba --- /dev/null +++ b/src/backend/InvenTree/generic/states/serializers.py @@ -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 + ) diff --git a/src/backend/InvenTree/generic/states/states.py b/src/backend/InvenTree/generic/states/states.py index 966ed76210..3304cc3e5c 100644 --- a/src/backend/InvenTree/generic/states/states.py +++ b/src/backend/InvenTree/generic/states/states.py @@ -1,9 +1,12 @@ """Generic implementation of status for InvenTree models.""" import enum +import logging import re from enum import Enum +logger = logging.getLogger('inventree') + class BaseEnum(enum.IntEnum): # noqa: PLW1641 """An `Enum` capabile of having its members have docstrings. @@ -102,10 +105,30 @@ class StatusCode(BaseEnum): return False 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 def values(cls, key=None): """Return a dict representation containing all required information.""" elements = [itm for itm in cls if cls._is_element(itm.name)] + if key is None: return elements @@ -138,19 +161,28 @@ class StatusCode(BaseEnum): return re.sub(r'(? 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 diff --git a/src/backend/InvenTree/generic/states/tests.py b/src/backend/InvenTree/generic/states/tests.py index f936cd8012..abb29369bd 100644 --- a/src/backend/InvenTree/generic/states/tests.py +++ b/src/backend/InvenTree/generic/states/tests.py @@ -174,10 +174,10 @@ class GeneralStateTest(InvenTreeTestCase): # Correct call resp = view(rqst, **{StatusView.MODEL_REF: GeneralStatus}) - self.assertEqual( + self.assertDictEqual( resp.data, { - 'class': 'GeneralStatus', + 'status_class': 'GeneralStatus', 'values': { 'COMPLETE': { 'key': 30, @@ -228,11 +228,13 @@ class ApiTests(InvenTreeAPITestCase): def test_all_states(self): """Test the API endpoint for listing all status models.""" response = self.get(reverse('api-status-all')) + + # 10 built-in state classes, plus the added GeneralState class self.assertEqual(len(response.data), 12) # Test the BuildStatus model 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) pending = build_status['values']['PENDING'] self.assertEqual(pending['key'], 10) @@ -241,7 +243,7 @@ class ApiTests(InvenTreeAPITestCase): # Test the StockStatus model (static) 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) in_stock = stock_status['values']['OK'] self.assertEqual(in_stock['key'], 10) @@ -249,8 +251,8 @@ class ApiTests(InvenTreeAPITestCase): self.assertEqual(in_stock['label'], 'OK') # MachineStatus model - machine_status = response.data['MachineStatus__LabelPrinterStatus'] - self.assertEqual(machine_status['class'], 'LabelPrinterStatus') + machine_status = response.data['LabelPrinterStatus'] + self.assertEqual(machine_status['status_class'], 'LabelPrinterStatus') self.assertEqual(len(machine_status['values']), 6) connected = machine_status['values']['CONNECTED'] self.assertEqual(connected['key'], 100) @@ -267,10 +269,11 @@ class ApiTests(InvenTreeAPITestCase): reference_status='StockStatus', ) response = self.get(reverse('api-status-all')) + self.assertEqual(len(response.data), 12) 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) ok_advanced = stock_status_cstm['values']['OK'] self.assertEqual(ok_advanced['key'], 10) diff --git a/src/backend/InvenTree/generic/states/validators.py b/src/backend/InvenTree/generic/states/validators.py new file mode 100644 index 0000000000..98aab7400a --- /dev/null +++ b/src/backend/InvenTree/generic/states/validators.py @@ -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')) diff --git a/src/backend/InvenTree/order/api.py b/src/backend/InvenTree/order/api.py index e9111f3284..fc4bb4763c 100644 --- a/src/backend/InvenTree/order/api.py +++ b/src/backend/InvenTree/order/api.py @@ -82,8 +82,14 @@ class OrderFilter(rest_filters.FilterSet): status = rest_filters.NumberFilter(label=_('Order Status'), method='filter_status') def filter_status(self, queryset, name, value): - """Filter by integer status code.""" - return queryset.filter(status=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() # Exact match for reference reference = rest_filters.CharFilter( diff --git a/src/backend/InvenTree/order/migrations/0101_purchaseorder_status_custom_key_and_more.py b/src/backend/InvenTree/order/migrations/0101_purchaseorder_status_custom_key_and_more.py index 26993943b5..7a36892d73 100644 --- a/src/backend/InvenTree/order/migrations/0101_purchaseorder_status_custom_key_and_more.py +++ b/src/backend/InvenTree/order/migrations/0101_purchaseorder_status_custom_key_and_more.py @@ -3,6 +3,7 @@ from django.db import migrations import generic.states.fields +import generic.states.validators import InvenTree.status_codes @@ -22,6 +23,11 @@ class Migration(migrations.Migration): help_text="Additional status information for this item", null=True, verbose_name="Custom status key", + validators=[ + generic.states.validators.CustomStatusCodeValidator( + status_class=InvenTree.status_codes.PurchaseOrderStatus + ), + ] ), ), migrations.AddField( @@ -33,6 +39,11 @@ class Migration(migrations.Migration): help_text="Additional status information for this item", null=True, verbose_name="Custom status key", + validators=[ + generic.states.validators.CustomStatusCodeValidator( + status_class=InvenTree.status_codes.ReturnOrderStatus + ), + ] ), ), migrations.AddField( @@ -44,6 +55,11 @@ class Migration(migrations.Migration): help_text="Additional status information for this item", null=True, verbose_name="Custom status key", + validators=[ + generic.states.validators.CustomStatusCodeValidator( + status_class=InvenTree.status_codes.ReturnOrderLineStatus + ), + ] ), ), migrations.AddField( @@ -55,6 +71,11 @@ class Migration(migrations.Migration): help_text="Additional status information for this item", null=True, verbose_name="Custom status key", + validators=[ + generic.states.validators.CustomStatusCodeValidator( + status_class=InvenTree.status_codes.SalesOrderStatus + ), + ] ), ), migrations.AlterField( @@ -65,6 +86,11 @@ class Migration(migrations.Migration): default=10, help_text="Purchase order status", verbose_name="Status", + validators=[ + generic.states.validators.CustomStatusCodeValidator( + status_class=InvenTree.status_codes.PurchaseOrderStatus + ), + ] ), ), migrations.AlterField( @@ -75,6 +101,11 @@ class Migration(migrations.Migration): default=10, help_text="Return order status", verbose_name="Status", + validators=[ + generic.states.validators.CustomStatusCodeValidator( + status_class=InvenTree.status_codes.ReturnOrderStatus + ), + ] ), ), migrations.AlterField( @@ -85,6 +116,11 @@ class Migration(migrations.Migration): default=10, help_text="Outcome for this line item", verbose_name="Outcome", + validators=[ + generic.states.validators.CustomStatusCodeValidator( + status_class=InvenTree.status_codes.ReturnOrderLineStatus + ), + ] ), ), migrations.AlterField( @@ -95,6 +131,11 @@ class Migration(migrations.Migration): default=10, help_text="Sales order status", verbose_name="Status", + validators=[ + generic.states.validators.CustomStatusCodeValidator( + status_class=InvenTree.status_codes.SalesOrderStatus + ), + ] ), ), ] diff --git a/src/backend/InvenTree/order/models.py b/src/backend/InvenTree/order/models.py index 588f107f50..8b66d67e9c 100644 --- a/src/backend/InvenTree/order/models.py +++ b/src/backend/InvenTree/order/models.py @@ -34,7 +34,7 @@ from common.currency import currency_code_default from common.notifications import InvenTreeNotificationBodies from common.settings import get_global_setting 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 InvenTree.exceptions import log_error from InvenTree.fields import ( @@ -179,6 +179,7 @@ class TotalPriceMixin(models.Model): class Order( + StatusCodeMixin, StateTransitionMixin, InvenTree.models.InvenTreeAttachmentMixin, InvenTree.models.InvenTreeBarcodeMixin, @@ -379,6 +380,7 @@ class PurchaseOrder(TotalPriceMixin, Order): REFERENCE_PATTERN_SETTING = 'PURCHASEORDER_REFERENCE_PATTERN' REQUIRE_RESPONSIBLE_SETTING = 'PURCHASEORDER_REQUIRE_RESPONSIBLE' + STATUS_CLASS = PurchaseOrderStatus class Meta: """Model meta options.""" @@ -483,6 +485,7 @@ class PurchaseOrder(TotalPriceMixin, Order): status = InvenTreeCustomStatusModelField( default=PurchaseOrderStatus.PENDING.value, choices=PurchaseOrderStatus.items(), + status_class=PurchaseOrderStatus, verbose_name=_('Status'), help_text=_('Purchase order status'), ) @@ -912,6 +915,7 @@ class SalesOrder(TotalPriceMixin, Order): REFERENCE_PATTERN_SETTING = 'SALESORDER_REFERENCE_PATTERN' REQUIRE_RESPONSIBLE_SETTING = 'SALESORDER_REQUIRE_RESPONSIBLE' + STATUS_CLASS = SalesOrderStatus class Meta: """Model meta options.""" @@ -1029,6 +1033,7 @@ class SalesOrder(TotalPriceMixin, Order): status = InvenTreeCustomStatusModelField( default=SalesOrderStatus.PENDING.value, choices=SalesOrderStatus.items(), + status_class=SalesOrderStatus, verbose_name=_('Status'), help_text=_('Sales order status'), ) @@ -2155,6 +2160,7 @@ class ReturnOrder(TotalPriceMixin, Order): REFERENCE_PATTERN_SETTING = 'RETURNORDER_REFERENCE_PATTERN' REQUIRE_RESPONSIBLE_SETTING = 'RETURNORDER_REQUIRE_RESPONSIBLE' + STATUS_CLASS = ReturnOrderStatus class Meta: """Model meta options.""" @@ -2231,6 +2237,7 @@ class ReturnOrder(TotalPriceMixin, Order): status = InvenTreeCustomStatusModelField( default=ReturnOrderStatus.PENDING.value, choices=ReturnOrderStatus.items(), + status_class=ReturnOrderStatus, verbose_name=_('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.""" + STATUS_CLASS = ReturnOrderLineStatus + STATUS_FIELD = 'outcome' + class Meta: """Metaclass options for this model.""" @@ -2522,6 +2532,7 @@ class ReturnOrderLineItem(OrderLineItem): outcome = InvenTreeCustomStatusModelField( default=ReturnOrderLineStatus.PENDING.value, choices=ReturnOrderLineStatus.items(), + status_class=ReturnOrderLineStatus, verbose_name=_('Outcome'), help_text=_('Outcome for this line item'), ) diff --git a/src/backend/InvenTree/order/serializers.py b/src/backend/InvenTree/order/serializers.py index bf6aa32fcf..de12e62f27 100644 --- a/src/backend/InvenTree/order/serializers.py +++ b/src/backend/InvenTree/order/serializers.py @@ -769,7 +769,9 @@ class PurchaseOrderLineItemReceiveSerializer(serializers.Serializer): ) 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( @@ -1935,7 +1937,7 @@ class ReturnOrderLineItemReceiveSerializer(serializers.Serializer): ) status = serializers.ChoiceField( - choices=stock.status_codes.StockStatus.items(), + choices=stock.status_codes.StockStatus.items(custom=True), default=None, label=_('Status'), help_text=_('Stock item status code'), diff --git a/src/backend/InvenTree/stock/api.py b/src/backend/InvenTree/stock/api.py index 0fe2326c70..ab8bcbe0bb 100644 --- a/src/backend/InvenTree/stock/api.py +++ b/src/backend/InvenTree/stock/api.py @@ -571,8 +571,14 @@ class StockFilter(rest_filters.FilterSet): status = rest_filters.NumberFilter(label=_('Status Code'), method='filter_status') def filter_status(self, queryset, name, value): - """Filter by integer status code.""" - return queryset.filter(status=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() allocated = rest_filters.BooleanFilter( label='Is Allocated', method='filter_allocated' diff --git a/src/backend/InvenTree/stock/migrations/0113_stockitem_status_custom_key_and_more.py b/src/backend/InvenTree/stock/migrations/0113_stockitem_status_custom_key_and_more.py index 5b9bc7e8d6..1b8bbf1304 100644 --- a/src/backend/InvenTree/stock/migrations/0113_stockitem_status_custom_key_and_more.py +++ b/src/backend/InvenTree/stock/migrations/0113_stockitem_status_custom_key_and_more.py @@ -5,6 +5,7 @@ from django.db import migrations import generic.states import generic.states.fields +import generic.states.validators import InvenTree.status_codes @@ -24,6 +25,11 @@ class Migration(migrations.Migration): help_text="Additional status information for this item", null=True, verbose_name="Custom status key", + validators=[ + generic.states.validators.CustomStatusCodeValidator( + status_class=InvenTree.status_codes.StockStatus + ), + ], ), ), migrations.AlterField( @@ -32,7 +38,12 @@ class Migration(migrations.Migration): field=generic.states.fields.InvenTreeCustomStatusModelField( choices=InvenTree.status_codes.StockStatus.items(), default=10, - validators=[django.core.validators.MinValueValidator(0)], + validators=[ + django.core.validators.MinValueValidator(0), + generic.states.validators.CustomStatusCodeValidator( + status_class=InvenTree.status_codes.StockStatus + ), + ], ), ), ] diff --git a/src/backend/InvenTree/stock/models.py b/src/backend/InvenTree/stock/models.py index a8baa6c475..6e70ba16cc 100644 --- a/src/backend/InvenTree/stock/models.py +++ b/src/backend/InvenTree/stock/models.py @@ -37,6 +37,7 @@ import stock.tasks from common.icons import validate_icon from common.settings import get_global_setting from company import models as CompanyModels +from generic.states import StatusCodeMixin from generic.states.fields import InvenTreeCustomStatusModelField from InvenTree.fields import InvenTreeModelMoneyField, InvenTreeURLField from InvenTree.status_codes import ( @@ -340,6 +341,7 @@ class StockItem( InvenTree.models.InvenTreeAttachmentMixin, InvenTree.models.InvenTreeBarcodeMixin, InvenTree.models.InvenTreeNotesMixin, + StatusCodeMixin, report.mixins.InvenTreeReportMixin, InvenTree.models.MetadataMixin, InvenTree.models.PluginValidationMixin, @@ -373,6 +375,8 @@ class StockItem( packaging: Description of how the StockItem is packaged (e.g. "reel", "loose", "tape" etc) """ + STATUS_CLASS = StockStatus + class Meta: """Model meta options.""" @@ -1020,6 +1024,7 @@ class StockItem( status = InvenTreeCustomStatusModelField( default=StockStatus.OK.value, + status_class=StockStatus, choices=StockStatus.items(), validators=[MinValueValidator(0)], ) @@ -2137,6 +2142,12 @@ class StockItem( else: 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 for field in StockItem.optional_transfer_fields(): if field in kwargs: @@ -2214,8 +2225,16 @@ class StockItem( if count < 0: 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): - tracking_info = {'quantity': float(count)} + tracking_info['quantity'] = float(count) self.stocktake_date = InvenTree.helpers.current_date() self.stocktake_user = user @@ -2269,8 +2288,17 @@ class StockItem( if quantity <= 0: 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): - 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 for field in StockItem.optional_transfer_fields(): @@ -2314,8 +2342,17 @@ class StockItem( if quantity <= 0: 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): - deltas = {'removed': float(quantity), 'quantity': float(self.quantity)} + deltas['removed'] = float(quantity) + deltas['quantity'] = float(self.quantity) if location := kwargs.get('location'): deltas['location'] = location.pk diff --git a/src/backend/InvenTree/stock/serializers.py b/src/backend/InvenTree/stock/serializers.py index 49cb20b27d..79f75b4388 100644 --- a/src/backend/InvenTree/stock/serializers.py +++ b/src/backend/InvenTree/stock/serializers.py @@ -980,7 +980,7 @@ class ReturnStockItemSerializer(serializers.Serializer): ) status = serializers.ChoiceField( - choices=stock.status_codes.StockStatus.items(), + choices=stock.status_codes.StockStatus.items(custom=True), default=None, label=_('Status'), help_text=_('Stock item status code'), @@ -996,7 +996,7 @@ class ReturnStockItemSerializer(serializers.Serializer): ) 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'] request = self.context['request'] @@ -1037,7 +1037,7 @@ class StockChangeStatusSerializer(serializers.Serializer): return items status = serializers.ChoiceField( - choices=stock.status_codes.StockStatus.items(), + choices=stock.status_codes.StockStatus.items(custom=True), default=stock.status_codes.StockStatus.OK.value, label=_('Status'), ) @@ -1533,11 +1533,11 @@ def stock_item_adjust_status_options(): 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): - """Serializer for a single StockItem within a stock adjument request. + """Serializer for a single StockItem within a stock adjustment request. Required Fields: - item: StockItem object diff --git a/src/frontend/src/components/render/StatusRenderer.tsx b/src/frontend/src/components/render/StatusRenderer.tsx index 7fc1def3ee..f13d0ce36b 100644 --- a/src/frontend/src/components/render/StatusRenderer.tsx +++ b/src/frontend/src/components/render/StatusRenderer.tsx @@ -1,19 +1,22 @@ 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 { resolveItem } from '../../functions/conversion'; import { useGlobalStatusState } from '../../states/StatusState'; -interface StatusCodeInterface { - key: string; +export interface StatusCodeInterface { + key: number; label: string; name: string; color: string; } export interface StatusCodeListInterface { - [key: string]: StatusCodeInterface; + status_class: string; + values: { + [key: string]: StatusCodeInterface; + }; } interface RenderStatusLabelOptionsInterface { @@ -33,10 +36,10 @@ function renderStatusLabel( let color = null; // Find the entry which matches the provided key - for (const name in codes) { - const entry = codes[name]; + for (const name in codes.values) { + const entry: StatusCodeInterface = codes.values[name]; - if (entry.key == key) { + if (entry?.key == key) { text = entry.label; color = entry.color; break; @@ -51,7 +54,7 @@ function renderStatusLabel( // Fallbacks if (color == null) color = 'default'; - color = colorMap[color] || colorMap['default']; + color = statusColorMap[color] || statusColorMap['default']; const size = options.size || 'xs'; 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; if (statusCodeList === undefined) { @@ -97,7 +102,7 @@ export function getStatusCodeName( } for (const name in statusCodes) { - const entry = statusCodes[name]; + const entry: StatusCodeInterface = statusCodes.values[name]; if (entry.key == key) { return entry.name; diff --git a/src/frontend/src/defaults/backendMappings.tsx b/src/frontend/src/defaults/backendMappings.tsx index ddfb396413..96b3f15e4f 100644 --- a/src/frontend/src/defaults/backendMappings.tsx +++ b/src/frontend/src/defaults/backendMappings.tsx @@ -20,7 +20,7 @@ export const statusCodeList: Record = { /* * 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', warning: 'yellow', success: 'green', diff --git a/src/frontend/src/forms/CommonForms.tsx b/src/frontend/src/forms/CommonForms.tsx index 3f20dff131..ba4b3d09e1 100644 --- a/src/frontend/src/forms/CommonForms.tsx +++ b/src/frontend/src/forms/CommonForms.tsx @@ -1,6 +1,12 @@ import { IconUsers } from '@tabler/icons-react'; +import { useMemo, useState } from 'react'; 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 { return { @@ -12,16 +18,51 @@ export function projectCodeFields(): ApiFormFieldSet { }; } -export function customStateFields(): ApiFormFieldSet { - return { - key: {}, - name: {}, - label: {}, - color: {}, - logical_key: {}, - model: {}, - reference_status: {} - }; +export function useCustomStateFields(): ApiFormFieldSet { + // Status codes + const statusCodes = useGlobalStatusState(); + + // Selected base status class + const [statusClass, setStatusClass] = useState(''); + + // Construct a list of status options based on the selected status class + 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 { diff --git a/src/frontend/src/forms/StockForms.tsx b/src/frontend/src/forms/StockForms.tsx index 4cc010c7e9..70692f0a92 100644 --- a/src/frontend/src/forms/StockForms.tsx +++ b/src/frontend/src/forms/StockForms.tsx @@ -482,7 +482,7 @@ function StockOperationsRow({ const [statusOpen, statusHandlers] = useDisclosure(false, { onOpen: () => { - setStatus(record?.status || undefined); + setStatus(record?.status_custom_key || record?.status || undefined); props.changeFn(props.idx, 'status', record?.status || undefined); }, onClose: () => { diff --git a/src/frontend/src/hooks/UseStatusCodes.tsx b/src/frontend/src/hooks/UseStatusCodes.tsx index fcc29bd060..9316ea9adf 100644 --- a/src/frontend/src/hooks/UseStatusCodes.tsx +++ b/src/frontend/src/hooks/UseStatusCodes.tsx @@ -31,14 +31,18 @@ export default function useStatusCodes({ const statusCodeList = useGlobalStatusState.getState().status; const codes = useMemo(() => { - const statusCodes = getStatusCodes(modelType) || {}; + const statusCodes = getStatusCodes(modelType) || null; const codesMap: Record = {}; - for (const name in statusCodes) { - codesMap[name] = statusCodes[name].key; + if (!statusCodes) { + return codesMap; } + Object.keys(statusCodes.values).forEach((name) => { + codesMap[name] = statusCodes.values[name].key; + }); + return codesMap; }, [modelType, statusCodeList]); diff --git a/src/frontend/src/pages/build/BuildDetail.tsx b/src/frontend/src/pages/build/BuildDetail.tsx index a2de259fdc..a3cd447de0 100644 --- a/src/frontend/src/pages/build/BuildDetail.tsx +++ b/src/frontend/src/pages/build/BuildDetail.tsx @@ -107,6 +107,15 @@ export default function BuildDetail() { label: t`Status`, 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', name: 'reference', diff --git a/src/frontend/src/pages/purchasing/PurchaseOrderDetail.tsx b/src/frontend/src/pages/purchasing/PurchaseOrderDetail.tsx index 74b0727346..f557b0886d 100644 --- a/src/frontend/src/pages/purchasing/PurchaseOrderDetail.tsx +++ b/src/frontend/src/pages/purchasing/PurchaseOrderDetail.tsx @@ -144,6 +144,15 @@ export default function PurchaseOrderDetail() { name: 'status', label: t`Status`, 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 } ]; diff --git a/src/frontend/src/pages/sales/ReturnOrderDetail.tsx b/src/frontend/src/pages/sales/ReturnOrderDetail.tsx index bd68d5d3e9..0320c949d7 100644 --- a/src/frontend/src/pages/sales/ReturnOrderDetail.tsx +++ b/src/frontend/src/pages/sales/ReturnOrderDetail.tsx @@ -115,6 +115,15 @@ export default function ReturnOrderDetail() { name: 'status', label: t`Status`, 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 } ]; diff --git a/src/frontend/src/pages/sales/SalesOrderDetail.tsx b/src/frontend/src/pages/sales/SalesOrderDetail.tsx index 7964094520..7484d9be0f 100644 --- a/src/frontend/src/pages/sales/SalesOrderDetail.tsx +++ b/src/frontend/src/pages/sales/SalesOrderDetail.tsx @@ -124,6 +124,15 @@ export default function SalesOrderDetail() { name: 'status', label: t`Status`, 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 } ]; diff --git a/src/frontend/src/pages/stock/StockDetail.tsx b/src/frontend/src/pages/stock/StockDetail.tsx index 0f408e0289..2168e856b9 100644 --- a/src/frontend/src/pages/stock/StockDetail.tsx +++ b/src/frontend/src/pages/stock/StockDetail.tsx @@ -133,11 +133,21 @@ export default function StockDetail() { icon: 'part', hidden: !part.IPN }, + { + name: 'status', + type: 'status', + label: t`Status`, + model: ModelType.stockitem + }, { name: 'status_custom_key', type: 'status', - label: t`Stock Status`, - model: ModelType.stockitem + label: t`Custom Status`, + model: ModelType.stockitem, + icon: 'status', + hidden: + !stockitem.status_custom_key || + stockitem.status_custom_key == stockitem.status }, { type: 'text', @@ -845,11 +855,10 @@ export default function StockDetail() { key='batch' />, , diff --git a/src/frontend/src/states/StatusState.tsx b/src/frontend/src/states/StatusState.tsx index 7f0eba824b..30a17a109c 100644 --- a/src/frontend/src/states/StatusState.tsx +++ b/src/frontend/src/states/StatusState.tsx @@ -9,7 +9,7 @@ import type { ModelType } from '../enums/ModelType'; import { apiUrl } from './ApiState'; import { useUserState } from './UserState'; -type StatusLookup = Record; +export type StatusLookup = Record; interface ServerStateProps { status?: StatusLookup; @@ -35,8 +35,10 @@ export const useGlobalStatusState = create()( .then((response) => { const newStatusLookup: StatusLookup = {} as StatusLookup; for (const key in response.data) { - newStatusLookup[statusCodeList[key] || key] = - response.data[key].values; + newStatusLookup[statusCodeList[key] || key] = { + status_class: key, + values: response.data[key].values + }; } set({ status: newStatusLookup }); }) diff --git a/src/frontend/src/tables/Filter.tsx b/src/frontend/src/tables/Filter.tsx index fb36f03f52..f53d5e37f4 100644 --- a/src/frontend/src/tables/Filter.tsx +++ b/src/frontend/src/tables/Filter.tsx @@ -1,7 +1,11 @@ import { t } from '@lingui/macro'; +import type { + StatusCodeInterface, + StatusCodeListInterface +} from '../components/render/StatusRenderer'; import type { ModelType } from '../enums/ModelType'; -import { useGlobalStatusState } from '../states/StatusState'; +import { type StatusLookup, useGlobalStatusState } from '../states/StatusState'; /** * Interface for the table filter choice @@ -71,17 +75,18 @@ export function StatusFilterOptions( model: ModelType ): () => TableFilterChoice[] { return () => { - const statusCodeList = useGlobalStatusState.getState().status; + const statusCodeList: StatusLookup | undefined = + useGlobalStatusState.getState().status; if (!statusCodeList) { return []; } - const codes = statusCodeList[model]; + const codes: StatusCodeListInterface | undefined = statusCodeList[model]; if (codes) { - return Object.keys(codes).map((key) => { - const entry = codes[key]; + return Object.keys(codes.values).map((key) => { + const entry: StatusCodeInterface = codes.values[key]; return { value: entry.key.toString(), label: entry.label?.toString() ?? entry.key.toString() diff --git a/src/frontend/src/tables/settings/CustomStateTable.tsx b/src/frontend/src/tables/settings/CustomStateTable.tsx index 594086569c..30f2960e2b 100644 --- a/src/frontend/src/tables/settings/CustomStateTable.tsx +++ b/src/frontend/src/tables/settings/CustomStateTable.tsx @@ -1,10 +1,16 @@ import { t } from '@lingui/macro'; +import { Badge } from '@mantine/core'; import { useCallback, useMemo, useState } from 'react'; 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 { UserRoles } from '../../enums/Roles'; -import { customStateFields } from '../../forms/CommonForms'; +import { useCustomStateFields } from '../../forms/CommonForms'; import { useCreateApiFormModal, useDeleteApiFormModal, @@ -12,10 +18,17 @@ import { } from '../../hooks/UseForm'; import { useTable } from '../../hooks/UseTable'; import { apiUrl } from '../../states/ApiState'; +import { useGlobalStatusState } from '../../states/StatusState'; import { useUserState } from '../../states/UserState'; import type { TableColumn } from '../Column'; +import type { TableFilter } from '../Filter'; 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 @@ -23,12 +36,64 @@ import { type RowAction, RowDeleteAction, RowEditAction } from '../RowActions'; export default function CustomStateTable() { 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 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(() => { 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', + title: t`Identifier`, sortable: true }, { @@ -36,34 +101,45 @@ export default function CustomStateTable() { title: t`Display Name`, sortable: true }, - { - accessor: 'color' - }, { accessor: 'key', sortable: true }, { - accessor: 'logical_key', - sortable: true - }, - { - accessor: 'model_name', - title: t`Model`, - sortable: true - }, - { - accessor: 'reference_status', - title: t`Status`, - sortable: true + accessor: 'color', + render: (record: any) => { + return ( + + {record.color} + + ); + } } ]; - }, []); + }, [getLogicalState]); + + const newCustomStateFields = useCustomStateFields(); + const duplicateCustomStateFields = useCustomStateFields(); + const editCustomStateFields = useCustomStateFields(); + + const [initialStateData, setInitialStateData] = useState({}); const newCustomState = useCreateApiFormModal({ url: ApiEndpoints.custom_state_list, 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 }); @@ -75,7 +151,7 @@ export default function CustomStateTable() { url: ApiEndpoints.custom_state_list, pk: selectedCustomState, title: t`Edit State`, - fields: customStateFields(), + fields: editCustomStateFields, table: table }); @@ -96,6 +172,13 @@ export default function CustomStateTable() { editCustomState.open(); } }), + RowDuplicateAction({ + hidden: !user.hasAddRole(UserRoles.admin), + onClick: () => { + setInitialStateData(record); + duplicateCustomState.open(); + } + }), RowDeleteAction({ hidden: !user.hasDeleteRole(UserRoles.admin), onClick: () => { @@ -112,7 +195,10 @@ export default function CustomStateTable() { return [ newCustomState.open()} + onClick={() => { + setInitialStateData({}); + newCustomState.open(); + }} tooltip={t`Add State`} /> ]; @@ -122,6 +208,7 @@ export default function CustomStateTable() { <> {newCustomState.modal} {editCustomState.modal} + {duplicateCustomState.modal} {deleteCustomState.modal} diff --git a/src/frontend/tests/helpers.ts b/src/frontend/tests/helpers.ts index a44bb60dcc..36d421c865 100644 --- a/src/frontend/tests/helpers.ts +++ b/src/frontend/tests/helpers.ts @@ -34,7 +34,7 @@ export const clickButtonIfVisible = async (page, name, timeout = 500) => { export const clearTableFilters = async (page) => { await openFilterDrawer(page); await clickButtonIfVisible(page, 'Clear Filters'); - await page.getByLabel('filter-drawer-close').click(); + await closeFilterDrawer(page); }; 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.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.getByRole('option', { name: value }).click(); diff --git a/src/frontend/tests/pages/pui_build.spec.ts b/src/frontend/tests/pages/pui_build.spec.ts index 154f5027f1..61475b738f 100644 --- a/src/frontend/tests/pages/pui_build.spec.ts +++ b/src/frontend/tests/pages/pui_build.spec.ts @@ -1,9 +1,9 @@ import { test } from '../baseFixtures.ts'; import { baseUrl } from '../defaults.ts'; import { - clickButtonIfVisible, + clearTableFilters, getRowFromCell, - openFilterDrawer + setTableChoiceFilter } from '../helpers.ts'; import { doQuickLogin } from '../login.ts'; @@ -266,6 +266,24 @@ test('Build Order - Filters', async ({ page }) => { await page.goto(`${baseUrl}/manufacturing/index/buildorders`); - await openFilterDrawer(page); - await clickButtonIfVisible(page, 'Clear Filters'); + await clearTableFilters(page); + 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(); }); diff --git a/src/frontend/tests/pages/pui_stock.spec.ts b/src/frontend/tests/pages/pui_stock.spec.ts index 54b10ce4bf..0d1fb20ecd 100644 --- a/src/frontend/tests/pages/pui_stock.spec.ts +++ b/src/frontend/tests/pages/pui_stock.spec.ts @@ -1,6 +1,11 @@ import { test } from '../baseFixtures.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'; test('Stock - Basic Tests', async ({ page }) => { @@ -84,9 +89,15 @@ test('Stock - Filters', async ({ page }) => { .getByRole('cell', { name: 'A round table - with blue paint' }) .waitFor(); - // Clear filters (ready for next set of tests) - await openFilterDrawer(page); - await clickButtonIfVisible(page, 'Clear Filters'); + // Filter by custom status code + await clearTableFilters(page); + 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 }) => { @@ -158,47 +169,58 @@ test('Stock - Serial Numbers', async ({ page }) => { test('Stock - Stock Actions', async ({ page }) => { await doQuickLogin(page); - // Find an in-stock, untracked item - await page.goto( - `${baseUrl}/stock/location/index/stock-items?in_stock=1&serialized=0` - ); - await page.getByText('530470210').first().click(); + await page.goto(`${baseUrl}/stock/item/1225/details`); + + // Helper function to launch a stock action + const launchStockAction = async (action: string) => { + 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 - .locator('div') - .filter({ hasText: /^Quantity: 270$/ }) - .first() + .getByLabel('Stock Details') + .getByText('Incoming goods inspection') .waitFor(); + await page.getByText('123').first().waitFor(); - // Check for expected action sections - await page.getByLabel('action-menu-barcode-actions').click(); - await page.getByLabel('action-menu-barcode-actions-link-barcode').click(); - await page.getByRole('banner').getByRole('button').click(); - - 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'); + // Add stock, and change status + await launchStockAction('add'); + await page.getByLabel('number-field-quantity').fill('12'); + await setStockStatus('Lost'); await page.getByRole('button', { name: 'Submit' }).click(); - await page.getByText('This field is required.').first().waitFor(); - // Set the status field - await page.getByLabel('action-button-change-status').click(); - await page.getByLabel('choice-field-status').click(); - await page.getByText('Attention needed').click(); + await page.getByText('Lost').first().waitFor(); + await page.getByText('Unavailable').first().waitFor(); + await page.getByText('135').first().waitFor(); - // Set the packaging field - await page.getByLabel('action-button-adjust-packaging').click(); - await page.getByLabel('text-field-packaging').fill('test packaging'); + // Remove stock, and change status + await launchStockAction('remove'); + await page.getByLabel('number-field-quantity').fill('99'); + await setStockStatus('Damaged'); + await page.getByRole('button', { name: 'Submit' }).click(); - // Close the dialog - await page.getByRole('button', { name: 'Cancel' }).click(); + await page.getByText('36').first().waitFor(); + 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 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.getByRole('link', { name: 'Widget Assembly' }).waitFor(); await page.getByRole('cell', { name: 'Installed into assembly' }).waitFor(); - - await page.waitForTimeout(1500); - return; }); diff --git a/src/frontend/tests/pui_settings.spec.ts b/src/frontend/tests/pui_settings.spec.ts index d1c3c89d88..81de594267 100644 --- a/src/frontend/tests/pui_settings.spec.ts +++ b/src/frontend/tests/pui_settings.spec.ts @@ -153,7 +153,7 @@ test('Settings - Admin - Barcode History', async ({ page, request }) => { await page.getByRole('menuitem', { name: 'Admin Center' }).click(); await page.getByRole('tab', { name: 'Barcode Scans' }).click(); - await page.waitForTimeout(2000); + await page.waitForTimeout(500); // Barcode history is displayed in table barcodes.forEach(async (barcode) => {