mirror of
https://github.com/inventree/InvenTree.git
synced 2025-05-03 22:08:49 +00:00
[Refactor] Custom states (#8438)
* Enhancements for "custom state" form - More intuitive form actions * Improve back-end validation * Improve table rendering * Fix lookup for useStatusCodes * Fix status display for SockDetail page * Fix SalesOrder status display * Refactor get_custom_classes - Add StatusCode.custom_values method * Fix for status table filters * Cleanup (and note to self) * Include custom state values in specific API endpoints * Add serializer class definition * Use same serializer for AllStatusView * Fix API to match existing frontend type StatusCodeListInterface * Enable filtering by reference status type * Add option to duplicate an existing custom state * Improved validation for the InvenTreeCustomUserStateModel class * Code cleanup * Fix default value in StockOperationsRow * Use custom status values in stock operations * Allow custom values * Fix migration * Bump API version * Fix filtering of stock items by "status" * Enhance status filter for orders * Fix status code rendering * Build Order API filter * Update playwright tests for build filters * Additional playwright tests for stock table filters * Add 'custom' attribute * Fix unit tests * Add custom state field validation * Implement StatusCodeMixin for setting status code values * Clear out 'custom key' if the base key does not match * Updated playwright testing * Remove timeout * Refactor detail pages which display status * Update old migrations - add field validator * Remove dead code * Simplify API query filtering * Revert "Simplify API query filtering" This reverts commit 06c858ae7ce1feab5af0f91993b42ba8a81e588a. * Fix save method * Unit test fixes * Fix for ReturnOrderLineItem * Reorganize code * Adjust unit test
This commit is contained in:
parent
c582ca0afd
commit
964984ccac
@ -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) => {
|
||||
|
Loading…
x
Reference in New Issue
Block a user