mirror of
https://github.com/inventree/InvenTree.git
synced 2025-06-17 20:45:44 +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
|
||||
|
Reference in New Issue
Block a user