2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-05-03 22:08:49 +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:
Oliver 2024-12-29 08:45:23 +11:00 committed by GitHub
parent c582ca0afd
commit 964984ccac
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
42 changed files with 916 additions and 262 deletions

View File

@ -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

View File

@ -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')

View File

@ -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",
),
),

View File

@ -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'),
)

View File

@ -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(

View File

@ -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')},
),
]

View File

@ -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.

View File

@ -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()
)

View File

@ -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'))

View File

@ -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',
]

View File

@ -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):

View File

@ -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()

View File

@ -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.

View 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
)

View File

@ -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'(?<!^)(?=[A-Z])', '_', ref_name).lower()
@classmethod
def items(cls):
def items(cls, custom=False):
"""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
def keys(cls):
def keys(cls, custom=True):
"""All status code keys."""
return [x.value for x in cls.values()]
return [el[0] for el in cls.items(custom=custom)]
@classmethod
def labels(cls):
def labels(cls, custom=True):
"""All status code labels."""
return [x.label for x in cls.values()]
return [el[1] for el in cls.items(custom=custom)]
@classmethod
def names(cls):
@ -174,23 +206,42 @@ class StatusCode(BaseEnum):
return filtered.label
@classmethod
def dict(cls, key=None):
def dict(cls, key=None, custom=True):
"""Return a dict representation containing all required information."""
return {
data = {
x.name: {'color': x.color, 'key': x.value, 'label': x.label, 'name': x.name}
for x in cls.values(key)
}
@classmethod
def list(cls):
"""Return the StatusCode options as a list of mapped key / value items."""
return list(cls.dict().values())
if custom:
try:
for item in cls.custom_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
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."""
ret = {x.name: x.value for x in cls.values()}
ret['list'] = cls.list()
data = cls.dict(custom=custom)
ret = {x['name']: x['key'] for x in data.values()}
ret['list'] = list(data.values())
return ret
@ -205,3 +256,78 @@ class ColorEnum(Enum):
warning = 'warning'
info = 'info'
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

View File

@ -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)

View 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'))

View File

@ -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(

View File

@ -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
),
]
),
),
]

View File

@ -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'),
)

View File

@ -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'),

View File

@ -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'

View File

@ -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
),
],
),
),
]

View File

@ -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

View File

@ -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

View File

@ -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;

View File

@ -20,7 +20,7 @@ export const statusCodeList: Record<string, ModelType> = {
/*
* 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',

View File

@ -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<string>('');
// 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 {

View File

@ -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: () => {

View File

@ -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<any, any> = {};
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]);

View File

@ -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',

View File

@ -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
}
];

View File

@ -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
}
];

View File

@ -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
}
];

View File

@ -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'
/>,
<StatusRenderer
status={stockitem.status_custom_key}
status={stockitem.status_custom_key || stockitem.status}
type={ModelType.stockitem}
options={{
size: 'lg',
hidden: !!stockitem.status_custom_key
size: 'lg'
}}
key='status'
/>,

View File

@ -9,7 +9,7 @@ import type { ModelType } from '../enums/ModelType';
import { apiUrl } from './ApiState';
import { useUserState } from './UserState';
type StatusLookup = Record<ModelType | string, StatusCodeListInterface>;
export type StatusLookup = Record<ModelType | string, StatusCodeListInterface>;
interface ServerStateProps {
status?: StatusLookup;
@ -35,8 +35,10 @@ export const useGlobalStatusState = create<ServerStateProps>()(
.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 });
})

View File

@ -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()

View File

@ -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 (
<Badge
color={statusColorMap[record.color] || statusColorMap['default']}
variant='filled'
size='xs'
>
{record.color}
</Badge>
);
}
}
];
}, []);
}, [getLogicalState]);
const newCustomStateFields = useCustomStateFields();
const duplicateCustomStateFields = useCustomStateFields();
const editCustomStateFields = useCustomStateFields();
const [initialStateData, setInitialStateData] = useState<any>({});
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 [
<AddItemButton
key={'add'}
onClick={() => 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}
<InvenTreeTable
url={apiUrl(ApiEndpoints.custom_state_list)}
@ -130,6 +217,7 @@ export default function CustomStateTable() {
props={{
rowActions: rowActions,
tableActions: tableActions,
tableFilters: tableFilters,
enableDownload: true
}}
/>

View File

@ -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();

View File

@ -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();
});

View File

@ -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;
});

View File

@ -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) => {