mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-11-04 07:05:41 +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 06c858ae7c.
* Fix save method
* Unit test fixes
* Fix for ReturnOrderLineItem
* Reorganize code
* Adjust unit test
			
			
This commit is contained in:
		@@ -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
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -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')
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -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",
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
 
 | 
			
		||||
@@ -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'),
 | 
			
		||||
    )
 | 
			
		||||
 
 | 
			
		||||
@@ -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(
 | 
			
		||||
 
 | 
			
		||||
@@ -0,0 +1,32 @@
 | 
			
		||||
# Generated by Django 4.2.17 on 2024-12-27 09:15
 | 
			
		||||
 | 
			
		||||
import common.validators
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ('common', '0033_delete_colortheme'),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AlterUniqueTogether(
 | 
			
		||||
            name='inventreecustomuserstatemodel',
 | 
			
		||||
            unique_together=set(),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name='inventreecustomuserstatemodel',
 | 
			
		||||
            name='key',
 | 
			
		||||
            field=models.IntegerField(help_text='Numerical value that will be saved in the models database', verbose_name='Value'),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name='inventreecustomuserstatemodel',
 | 
			
		||||
            name='name',
 | 
			
		||||
            field=models.CharField(help_text='Name of the state', max_length=250, validators=[common.validators.validate_uppercase, common.validators.validate_variable_string], verbose_name='Name'),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AlterUniqueTogether(
 | 
			
		||||
            name='inventreecustomuserstatemodel',
 | 
			
		||||
            unique_together={('reference_status', 'name'), ('reference_status', 'key')},
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
@@ -51,7 +51,7 @@ import InvenTree.validators
 | 
			
		||||
import users.models
 | 
			
		||||
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.
 | 
			
		||||
 
 | 
			
		||||
@@ -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()
 | 
			
		||||
    )
 | 
			
		||||
 
 | 
			
		||||
@@ -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'))
 | 
			
		||||
 
 | 
			
		||||
@@ -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',
 | 
			
		||||
]
 | 
			
		||||
 
 | 
			
		||||
@@ -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):
 | 
			
		||||
 
 | 
			
		||||
@@ -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()
 | 
			
		||||
 
 | 
			
		||||
@@ -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.
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										41
									
								
								src/backend/InvenTree/generic/states/serializers.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										41
									
								
								src/backend/InvenTree/generic/states/serializers.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,41 @@
 | 
			
		||||
"""Serializer classes for handling generic state information."""
 | 
			
		||||
 | 
			
		||||
from django.utils.translation import gettext_lazy as _
 | 
			
		||||
 | 
			
		||||
from rest_framework import serializers
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class GenericStateValueSerializer(serializers.Serializer):
 | 
			
		||||
    """API serializer for generic state information."""
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        """Meta class for GenericStateValueSerializer."""
 | 
			
		||||
 | 
			
		||||
        fields = ['key', 'logical_key', 'name', 'label', 'color', 'custom']
 | 
			
		||||
 | 
			
		||||
    key = serializers.IntegerField(label=_('Key'), required=True)
 | 
			
		||||
 | 
			
		||||
    logical_key = serializers.CharField(label=_('Logical Key'), required=False)
 | 
			
		||||
 | 
			
		||||
    name = serializers.CharField(label=_('Name'), required=True)
 | 
			
		||||
 | 
			
		||||
    label = serializers.CharField(label=_('Label'), required=True)
 | 
			
		||||
 | 
			
		||||
    color = serializers.CharField(label=_('Color'), required=False)
 | 
			
		||||
 | 
			
		||||
    custom = serializers.BooleanField(label=_('Custom'), required=False)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class GenericStateClassSerializer(serializers.Serializer):
 | 
			
		||||
    """API serializer for generic state class information."""
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        """Meta class for GenericStateClassSerializer."""
 | 
			
		||||
 | 
			
		||||
        fields = ['status_class', 'values']
 | 
			
		||||
 | 
			
		||||
    status_class = serializers.CharField(label=_('Class'), read_only=True)
 | 
			
		||||
 | 
			
		||||
    values = serializers.DictField(
 | 
			
		||||
        child=GenericStateValueSerializer(), label=_('Values'), required=True
 | 
			
		||||
    )
 | 
			
		||||
@@ -1,9 +1,12 @@
 | 
			
		||||
"""Generic implementation of status for InvenTree models."""
 | 
			
		||||
 | 
			
		||||
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
 | 
			
		||||
 
 | 
			
		||||
@@ -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)
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										21
									
								
								src/backend/InvenTree/generic/states/validators.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								src/backend/InvenTree/generic/states/validators.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,21 @@
 | 
			
		||||
"""Validators for generic state management."""
 | 
			
		||||
 | 
			
		||||
from django.core.exceptions import ValidationError
 | 
			
		||||
from django.core.validators import BaseValidator
 | 
			
		||||
from django.utils.translation import gettext_lazy as _
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class CustomStatusCodeValidator(BaseValidator):
 | 
			
		||||
    """Custom validator class for checking that a provided status code is valid."""
 | 
			
		||||
 | 
			
		||||
    def __init__(self, *args, **kwargs):
 | 
			
		||||
        """Initialize the validator."""
 | 
			
		||||
        self.status_class = kwargs.pop('status_class', None)
 | 
			
		||||
        super().__init__(limit_value=None, **kwargs)
 | 
			
		||||
 | 
			
		||||
    def __call__(self, value):
 | 
			
		||||
        """Check that the provided status code is valid."""
 | 
			
		||||
        if status_class := self.status_class:
 | 
			
		||||
            values = status_class.keys(custom=True)
 | 
			
		||||
            if value not in values:
 | 
			
		||||
                raise ValidationError(_('Invalid status code'))
 | 
			
		||||
@@ -82,8 +82,14 @@ class OrderFilter(rest_filters.FilterSet):
 | 
			
		||||
    status = rest_filters.NumberFilter(label=_('Order Status'), method='filter_status')
 | 
			
		||||
 | 
			
		||||
    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(
 | 
			
		||||
 
 | 
			
		||||
@@ -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
 | 
			
		||||
                    ),
 | 
			
		||||
                ]
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
 
 | 
			
		||||
@@ -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'),
 | 
			
		||||
    )
 | 
			
		||||
 
 | 
			
		||||
@@ -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'),
 | 
			
		||||
 
 | 
			
		||||
@@ -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'
 | 
			
		||||
 
 | 
			
		||||
@@ -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
 | 
			
		||||
                    ),
 | 
			
		||||
                ],
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
 
 | 
			
		||||
@@ -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
 | 
			
		||||
 
 | 
			
		||||
@@ -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
 | 
			
		||||
 
 | 
			
		||||
@@ -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;
 | 
			
		||||
 
 | 
			
		||||
@@ -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',
 | 
			
		||||
 
 | 
			
		||||
@@ -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 {
 | 
			
		||||
 
 | 
			
		||||
@@ -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: () => {
 | 
			
		||||
 
 | 
			
		||||
@@ -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]);
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -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',
 | 
			
		||||
 
 | 
			
		||||
@@ -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
 | 
			
		||||
      }
 | 
			
		||||
    ];
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -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
 | 
			
		||||
      }
 | 
			
		||||
    ];
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -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
 | 
			
		||||
      }
 | 
			
		||||
    ];
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -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'
 | 
			
		||||
          />,
 | 
			
		||||
 
 | 
			
		||||
@@ -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 });
 | 
			
		||||
          })
 | 
			
		||||
 
 | 
			
		||||
@@ -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()
 | 
			
		||||
 
 | 
			
		||||
@@ -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
 | 
			
		||||
        }}
 | 
			
		||||
      />
 | 
			
		||||
 
 | 
			
		||||
@@ -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();
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -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();
 | 
			
		||||
});
 | 
			
		||||
 
 | 
			
		||||
@@ -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;
 | 
			
		||||
});
 | 
			
		||||
 
 | 
			
		||||
@@ -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) => {
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user