/',
+ include([path('', StatusView.as_view(), name='api-status')]),
+ ),
+ path('', AllStatusViews.as_view(), name='api-status-all'),
+ ]),
+ ),
+]
diff --git a/src/backend/InvenTree/generic/states/custom.py b/src/backend/InvenTree/generic/states/custom.py
new file mode 100644
index 000000000000..2539eb550ebe
--- /dev/null
+++ b/src/backend/InvenTree/generic/states/custom.py
@@ -0,0 +1,88 @@
+"""Helper functions for custom status labels."""
+
+from InvenTree.helpers import inheritors
+
+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)
+ }
+
+
+def state_color_mappings():
+ """Return a list of custom user state colors."""
+ return [(a.name, a.value) for a in ColorEnum]
+
+
+def state_reference_mappings():
+ """Return a list of custom user state references."""
+ return [(a.__name__, a.__name__) for a in get_custom_classes(include_custom=False)]
+
+
+def get_logical_value(value, model: str):
+ """Return the state model for the selected value."""
+ from common.models import InvenTreeCustomUserStateModel
+
+ return InvenTreeCustomUserStateModel.objects.get(key=value, model__model=model)
+
+
+def get_custom_classes(
+ include_custom: bool = True, base_class=StatusCode, subclass=False
+):
+ """Return a dict of status classes (custom and class defined)."""
+ discovered_classes = inheritors(base_class, subclass)
+
+ if not include_custom:
+ return discovered_classes
+
+ # Gather DB settings
+ from common.models import InvenTreeCustomUserStateModel
+
+ custom_db_states = {}
+ custom_db_mdls = {}
+ for item in list(InvenTreeCustomUserStateModel.objects.all()):
+ if not custom_db_states.get(item.reference_status):
+ custom_db_states[item.reference_status] = []
+ custom_db_states[item.reference_status].append(item)
+ custom_db_mdls[item.model.app_label] = item.reference_status
+ custom_db_mdls_keys = custom_db_mdls.keys()
+
+ states = {}
+ for cls in discovered_classes:
+ tag = cls.tag()
+ states[tag] = cls
+ if custom_db_mdls and tag in custom_db_mdls_keys:
+ data = [(str(m.name), (m.value, m.label, m.color)) for m in states[tag]]
+ data_keys = [i[0] for i in data]
+
+ # Extent with non present tags
+ for entry in custom_db_states[custom_db_mdls[tag]]:
+ ref_name = str(entry.name.upper().replace(' ', ''))
+ if ref_name not in data_keys:
+ data += [
+ (
+ str(entry.name.upper().replace(' ', '')),
+ (entry.key, entry.label, entry.color),
+ )
+ ]
+
+ # Re-assemble the enum
+ states[tag] = base_class(f'{tag.capitalize()}Status', data)
+ return states.values()
diff --git a/src/backend/InvenTree/generic/states/fields.py b/src/backend/InvenTree/generic/states/fields.py
new file mode 100644
index 000000000000..99717d4884a9
--- /dev/null
+++ b/src/backend/InvenTree/generic/states/fields.py
@@ -0,0 +1,241 @@
+"""Custom model/serializer fields for InvenTree models that support custom states."""
+
+from typing import Any, Iterable, Optional
+
+from django.core.exceptions import ObjectDoesNotExist
+from django.db import models
+from django.utils.encoding import force_str
+from django.utils.translation import gettext_lazy as _
+
+from rest_framework import serializers
+from rest_framework.fields import ChoiceField
+
+from .custom import get_logical_value
+
+
+class CustomChoiceField(serializers.ChoiceField):
+ """Custom Choice Field.
+
+ This is not intended to be used directly.
+ """
+
+ def __init__(self, choices: Iterable, **kwargs):
+ """Initialize the field."""
+ choice_mdl = kwargs.pop('choice_mdl', None)
+ choice_field = kwargs.pop('choice_field', None)
+ is_custom = kwargs.pop('is_custom', False)
+ kwargs.pop('max_value', None)
+ kwargs.pop('min_value', None)
+ super().__init__(choices, **kwargs)
+ self.choice_mdl = choice_mdl
+ self.choice_field = choice_field
+ self.is_custom = is_custom
+
+ def to_internal_value(self, data):
+ """Map the choice (that might be a custom one) back to the logical value."""
+ try:
+ return super().to_internal_value(data)
+ except serializers.ValidationError:
+ try:
+ logical = get_logical_value(data, self.choice_mdl._meta.model_name)
+ if self.is_custom:
+ return logical.key
+ return logical.logical_key
+ except ObjectDoesNotExist:
+ raise serializers.ValidationError('Invalid choice')
+
+ def get_field_info(self, field, field_info):
+ """Return the field information for the given item."""
+ from common.models import InvenTreeCustomUserStateModel
+
+ # Static choices
+ choices = [
+ {
+ 'value': choice_value,
+ 'display_name': force_str(choice_name, strings_only=True),
+ }
+ for choice_value, choice_name in field.choices.items()
+ ]
+ # Dynamic choices from InvenTreeCustomUserStateModel
+ objs = InvenTreeCustomUserStateModel.objects.filter(
+ model__model=field.choice_mdl._meta.model_name
+ )
+ dyn_choices = [
+ {'value': choice.key, 'display_name': choice.label} for choice in objs.all()
+ ]
+
+ if dyn_choices:
+ all_choices = choices + dyn_choices
+ field_info['choices'] = sorted(all_choices, key=lambda kv: kv['value'])
+ else:
+ field_info['choices'] = choices
+ return field_info
+
+
+class ExtraCustomChoiceField(CustomChoiceField):
+ """Custom Choice Field that returns value of status if empty.
+
+ This is not intended to be used directly.
+ """
+
+ def to_representation(self, value):
+ """Return the value of the status if it is empty."""
+ return super().to_representation(value) or value
+
+
+class InvenTreeCustomStatusModelField(models.PositiveIntegerField):
+ """Custom model field for extendable status codes.
+
+ Adds a secondary *_custom_key field to the model which can be used to store additional status information.
+ Models using this model field must also include the InvenTreeCustomStatusSerializerMixin in all serializers that create or update the value.
+ """
+
+ def deconstruct(self):
+ """Deconstruct the field for migrations."""
+ name, path, args, kwargs = super().deconstruct()
+
+ return name, path, args, kwargs
+
+ def contribute_to_class(self, cls, name):
+ """Add the _custom_key field to the model."""
+ cls._meta.supports_custom_status = True
+
+ if not hasattr(self, '_custom_key_field'):
+ self.add_field(cls, name)
+
+ super().contribute_to_class(cls, name)
+
+ def clean(self, value: Any, model_instance: Any) -> Any:
+ """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."""
+ custom_key_field = ExtraInvenTreeCustomStatusModelField(
+ default=None,
+ verbose_name=_('Custom status key'),
+ help_text=_('Additional status information for this item'),
+ blank=True,
+ null=True,
+ )
+ cls.add_to_class(f'{name}_custom_key', custom_key_field)
+ self._custom_key_field = custom_key_field
+
+
+class ExtraInvenTreeCustomStatusModelField(models.PositiveIntegerField):
+ """Custom field used to detect custom extenteded fields.
+
+ This is not intended to be used directly, if you want to support custom states in your model use InvenTreeCustomStatusModelField.
+ """
+
+
+class InvenTreeCustomStatusSerializerMixin:
+ """Mixin to ensure custom status fields are set.
+
+ This mixin must be used to ensure that custom status fields are set correctly when updating a model.
+ """
+
+ _custom_fields: Optional[list] = None
+ _custom_fields_leader: Optional[list] = None
+ _custom_fields_follower: Optional[list] = None
+ _is_gathering = False
+
+ def update(self, instance, validated_data):
+ """Ensure the custom field is updated if the leader was changed."""
+ self.gather_custom_fields()
+ for field in self._custom_fields_leader:
+ if (
+ field in self.initial_data
+ and self.instance
+ and self.initial_data[field]
+ != getattr(self.instance, f'{field}_custom_key', None)
+ ):
+ setattr(self.instance, f'{field}_custom_key', self.initial_data[field])
+ for field in self._custom_fields_follower:
+ if (
+ field in validated_data
+ and field.replace('_custom_key', '') not in self.initial_data
+ ):
+ reference = get_logical_value(
+ validated_data[field],
+ self.fields[field].choice_mdl._meta.model_name,
+ )
+ validated_data[field.replace('_custom_key', '')] = reference.logical_key
+ return super().update(instance, validated_data)
+
+ def to_representation(self, instance):
+ """Ensure custom state fields are not served empty."""
+ data = super().to_representation(instance)
+ for field in self.gather_custom_fields():
+ if data[field] is None:
+ data[field] = data[
+ field.replace('_custom_key', '')
+ ] # Use "normal" status field instead
+ return data
+
+ def gather_custom_fields(self):
+ """Gather all custom fields on the serializer."""
+ if self._custom_fields_follower:
+ self._is_gathering = False
+ return self._custom_fields_follower
+
+ if self._is_gathering:
+ self._custom_fields = {}
+ else:
+ self._is_gathering = True
+ # Gather fields
+ self._custom_fields = {
+ k: v.is_custom
+ for k, v in self.fields.items()
+ if isinstance(v, CustomChoiceField)
+ }
+
+ # Separate fields for easier/cheaper access
+ self._custom_fields_follower = [k for k, v in self._custom_fields.items() if v]
+ self._custom_fields_leader = [
+ k for k, v in self._custom_fields.items() if not v
+ ]
+
+ return self._custom_fields_follower
+
+ def build_standard_field(self, field_name, model_field):
+ """Use custom field for custom status model.
+
+ This is required because of DRF overwriting all fields with choice sets.
+ """
+ field_cls, field_kwargs = super().build_standard_field(field_name, model_field)
+ if issubclass(field_cls, ChoiceField) and isinstance(
+ model_field, InvenTreeCustomStatusModelField
+ ):
+ field_cls = CustomChoiceField
+ field_kwargs['choice_mdl'] = model_field.model
+ field_kwargs['choice_field'] = model_field.name
+ elif isinstance(model_field, ExtraInvenTreeCustomStatusModelField):
+ field_cls = ExtraCustomChoiceField
+ field_kwargs['choice_mdl'] = model_field.model
+ field_kwargs['choice_field'] = model_field.name
+ field_kwargs['is_custom'] = True
+
+ # Inherit choices from leader
+ self.gather_custom_fields()
+ if field_name in self._custom_fields:
+ leader_field_name = field_name.replace('_custom_key', '')
+ leader_field = self.fields[leader_field_name]
+ if hasattr(leader_field, 'choices'):
+ field_kwargs['choices'] = list(leader_field.choices.items())
+ elif hasattr(model_field.model, leader_field_name):
+ leader_model_field = getattr(
+ model_field.model, leader_field_name
+ ).field
+ if hasattr(leader_model_field, 'choices'):
+ field_kwargs['choices'] = leader_model_field.choices
+
+ if getattr(leader_field, 'read_only', False) is True:
+ field_kwargs['read_only'] = True
+
+ if 'choices' not in field_kwargs:
+ field_kwargs['choices'] = []
+
+ return field_cls, field_kwargs
diff --git a/src/backend/InvenTree/generic/states/states.py b/src/backend/InvenTree/generic/states/states.py
index c72b201eca34..1ac7de117429 100644
--- a/src/backend/InvenTree/generic/states/states.py
+++ b/src/backend/InvenTree/generic/states/states.py
@@ -2,6 +2,7 @@
import enum
import re
+from enum import Enum
class BaseEnum(enum.IntEnum):
@@ -65,10 +66,23 @@ def __new__(cls, *args):
# Normal item definition
if len(args) == 1:
obj.label = args[0]
- obj.color = 'secondary'
+ obj.color = ColorEnum.secondary
else:
obj.label = args[1]
- obj.color = args[2] if len(args) > 2 else 'secondary'
+ obj.color = args[2] if len(args) > 2 else ColorEnum.secondary
+
+ # Ensure color is a valid value
+ if isinstance(obj.color, str):
+ try:
+ obj.color = ColorEnum(obj.color)
+ except ValueError:
+ raise ValueError(
+ f"Invalid color value '{obj.color}' for status '{obj.label}'"
+ )
+
+ # Set color value as string
+ obj.color = obj.color.value
+ obj.color_class = obj.color
return obj
@@ -181,3 +195,15 @@ def template_context(cls):
ret['list'] = cls.list()
return ret
+
+
+class ColorEnum(Enum):
+ """Enum for color values."""
+
+ primary = 'primary'
+ secondary = 'secondary'
+ success = 'success'
+ danger = 'danger'
+ warning = 'warning'
+ info = 'info'
+ dark = 'dark'
diff --git a/src/backend/InvenTree/generic/states/tags.py b/src/backend/InvenTree/generic/states/tags.py
index 19e1471b2840..f93a6b8a9654 100644
--- a/src/backend/InvenTree/generic/states/tags.py
+++ b/src/backend/InvenTree/generic/states/tags.py
@@ -3,15 +3,21 @@
from django.utils.safestring import mark_safe
from generic.templatetags.generic import register
-from InvenTree.helpers import inheritors
-from .states import StatusCode
+from .custom import get_custom_status_labels
@register.simple_tag
-def status_label(typ: str, key: int, *args, **kwargs):
+def status_label(typ: str, key: int, include_custom: bool = False, *args, **kwargs):
"""Render a status label."""
- state = {cls.tag(): cls for cls in inheritors(StatusCode)}.get(typ, None)
+ state = get_custom_status_labels(include_custom=include_custom).get(typ, None)
if state:
return mark_safe(state.render(key, large=kwargs.get('large', False)))
raise ValueError(f"Unknown status type '{typ}'")
+
+
+@register.simple_tag
+def display_status_label(typ: str, key: int, fallback: int, *args, **kwargs):
+ """Render a status label."""
+ render_key = int(key) if key else fallback
+ return status_label(typ, render_key, *args, include_custom=True, **kwargs)
diff --git a/src/backend/InvenTree/generic/states/tests.py b/src/backend/InvenTree/generic/states/tests.py
index c30da83ae363..dbba2d14b23e 100644
--- a/src/backend/InvenTree/generic/states/tests.py
+++ b/src/backend/InvenTree/generic/states/tests.py
@@ -1,11 +1,15 @@
"""Tests for the generic states module."""
+from django.contrib.contenttypes.models import ContentType
from django.test.client import RequestFactory
+from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from rest_framework.test import force_authenticate
-from InvenTree.unit_test import InvenTreeTestCase
+from common.models import InvenTreeCustomUserStateModel
+from generic.states import ColorEnum
+from InvenTree.unit_test import InvenTreeAPITestCase, InvenTreeTestCase
from .api import StatusView
from .states import StatusCode
@@ -14,9 +18,9 @@
class GeneralStatus(StatusCode):
"""Defines a set of status codes for tests."""
- PENDING = 10, _('Pending'), 'secondary'
+ PENDING = 10, _('Pending'), ColorEnum.secondary
PLACED = 20, _('Placed'), 'primary'
- COMPLETE = 30, _('Complete'), 'success'
+ COMPLETE = 30, _('Complete'), ColorEnum.success
ABC = None # This should be ignored
_DEF = None # This should be ignored
jkl = None # This should be ignored
@@ -120,8 +124,19 @@ def test_code_functions(self):
# label
self.assertEqual(GeneralStatus.label(10), 'Pending')
- def test_tag_function(self):
- """Test that the status code tag functions."""
+ def test_color(self):
+ """Test that the color enum validation works."""
+ with self.assertRaises(ValueError) as e:
+
+ class TTTT(StatusCode):
+ PENDING = 10, _('Pending'), 'invalid'
+
+ self.assertEqual(
+ str(e.exception), "Invalid color value 'invalid' for status 'Pending'"
+ )
+
+ def test_tag_status_label(self):
+ """Test that the status_label tag."""
from .tags import status_label
self.assertEqual(
@@ -137,6 +152,21 @@ def test_tag_function(self):
# Test non-existent key
self.assertEqual(status_label('general', 100), '100')
+ def test_tag_display_status_label(self):
+ """Test that the display_status_label tag (mainly the same as status_label)."""
+ from .tags import display_status_label
+
+ self.assertEqual(
+ display_status_label('general', 10, 11),
+ "Pending",
+ )
+ # Fallback
+ self.assertEqual(display_status_label('general', None, 11), '11')
+ self.assertEqual(
+ display_status_label('general', None, 10),
+ "Pending",
+ )
+
def test_api(self):
"""Test StatusView API view."""
view = StatusView.as_view()
@@ -191,3 +221,59 @@ def test_api(self):
self.assertEqual(
str(e.exception), '`status_class` not a valid StatusCode class'
)
+
+
+class ApiTests(InvenTreeAPITestCase):
+ """Test the API for the generic states module."""
+
+ def test_all_states(self):
+ """Test the API endpoint for listing all status models."""
+ response = self.get(reverse('api-status-all'))
+ self.assertEqual(len(response.data), 12)
+
+ # Test the BuildStatus model
+ build_status = response.data['BuildStatus']
+ self.assertEqual(build_status['class'], 'BuildStatus')
+ self.assertEqual(len(build_status['values']), 5)
+ pending = build_status['values']['PENDING']
+ self.assertEqual(pending['key'], 10)
+ self.assertEqual(pending['name'], 'PENDING')
+ self.assertEqual(pending['label'], 'Pending')
+
+ # Test the StockStatus model (static)
+ stock_status = response.data['StockStatus']
+ self.assertEqual(stock_status['class'], 'StockStatus')
+ self.assertEqual(len(stock_status['values']), 8)
+ in_stock = stock_status['values']['OK']
+ self.assertEqual(in_stock['key'], 10)
+ self.assertEqual(in_stock['name'], 'OK')
+ self.assertEqual(in_stock['label'], 'OK')
+
+ # MachineStatus model
+ machine_status = response.data['MachineStatus__LabelPrinterStatus']
+ self.assertEqual(machine_status['class'], 'LabelPrinterStatus')
+ self.assertEqual(len(machine_status['values']), 6)
+ connected = machine_status['values']['CONNECTED']
+ self.assertEqual(connected['key'], 100)
+ self.assertEqual(connected['name'], 'CONNECTED')
+
+ # Add custom status
+ InvenTreeCustomUserStateModel.objects.create(
+ key=11,
+ name='OK - advanced',
+ label='OK - adv.',
+ color='secondary',
+ logical_key=10,
+ model=ContentType.objects.get(model='stockitem'),
+ 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(len(stock_status_cstm['values']), 9)
+ ok_advanced = stock_status_cstm['values']['OK']
+ self.assertEqual(ok_advanced['key'], 10)
+ self.assertEqual(ok_advanced['name'], 'OK')
+ self.assertEqual(ok_advanced['label'], 'OK')
diff --git a/src/backend/InvenTree/importer/status_codes.py b/src/backend/InvenTree/importer/status_codes.py
index 71d4dfd0e69a..2a884cec1775 100644
--- a/src/backend/InvenTree/importer/status_codes.py
+++ b/src/backend/InvenTree/importer/status_codes.py
@@ -2,18 +2,26 @@
from django.utils.translation import gettext_lazy as _
-from generic.states import StatusCode
+from generic.states import ColorEnum, StatusCode
class DataImportStatusCode(StatusCode):
"""Defines a set of status codes for a DataImportSession."""
- INITIAL = 0, _('Initializing'), 'secondary' # Import session has been created
- MAPPING = 10, _('Mapping Columns'), 'primary' # Import fields are being mapped
- IMPORTING = 20, _('Importing Data'), 'primary' # Data is being imported
+ INITIAL = (
+ 0,
+ _('Initializing'),
+ ColorEnum.secondary,
+ ) # Import session has been created
+ MAPPING = (
+ 10,
+ _('Mapping Columns'),
+ ColorEnum.primary,
+ ) # Import fields are being mapped
+ IMPORTING = 20, _('Importing Data'), ColorEnum.primary # Data is being imported
PROCESSING = (
30,
_('Processing Data'),
- 'primary',
+ ColorEnum.primary,
) # Data is being processed by the user
- COMPLETE = 40, _('Complete'), 'success' # Import has been completed
+ COMPLETE = 40, _('Complete'), ColorEnum.success # Import has been completed
diff --git a/src/backend/InvenTree/machine/machine_types/label_printer.py b/src/backend/InvenTree/machine/machine_types/label_printer.py
index e2817322c5a5..119da641afb2 100644
--- a/src/backend/InvenTree/machine/machine_types/label_printer.py
+++ b/src/backend/InvenTree/machine/machine_types/label_printer.py
@@ -12,6 +12,7 @@
from rest_framework import serializers
from rest_framework.request import Request
+from generic.states import ColorEnum
from machine.machine_type import BaseDriver, BaseMachineType, MachineStatus
from plugin import registry as plg_registry
from plugin.base.label.mixins import LabelPrintingMixin
@@ -228,12 +229,12 @@ class LabelPrinterStatus(MachineStatus):
DISCONNECTED: The driver cannot establish a connection to the printer
"""
- CONNECTED = 100, _('Connected'), 'success'
- UNKNOWN = 101, _('Unknown'), 'secondary'
- PRINTING = 110, _('Printing'), 'primary'
- NO_MEDIA = 301, _('No media'), 'warning'
- PAPER_JAM = 302, _('Paper jam'), 'warning'
- DISCONNECTED = 400, _('Disconnected'), 'danger'
+ CONNECTED = 100, _('Connected'), ColorEnum.success
+ UNKNOWN = 101, _('Unknown'), ColorEnum.secondary
+ PRINTING = 110, _('Printing'), ColorEnum.primary
+ NO_MEDIA = 301, _('No media'), ColorEnum.warning
+ PAPER_JAM = 302, _('Paper jam'), ColorEnum.warning
+ DISCONNECTED = 400, _('Disconnected'), ColorEnum.danger
class LabelPrinterMachine(BaseMachineType):
diff --git a/src/backend/InvenTree/order/migrations/0101_purchaseorder_status_custom_key_and_more.py b/src/backend/InvenTree/order/migrations/0101_purchaseorder_status_custom_key_and_more.py
new file mode 100644
index 000000000000..26993943b5e8
--- /dev/null
+++ b/src/backend/InvenTree/order/migrations/0101_purchaseorder_status_custom_key_and_more.py
@@ -0,0 +1,100 @@
+# Generated by Django 4.2.14 on 2024-08-07 22:40
+
+from django.db import migrations
+
+import generic.states.fields
+import InvenTree.status_codes
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("order", "0100_remove_returnorderattachment_order_and_more"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="purchaseorder",
+ name="status_custom_key",
+ field=generic.states.fields.ExtraInvenTreeCustomStatusModelField(
+ blank=True,
+ default=None,
+ help_text="Additional status information for this item",
+ null=True,
+ verbose_name="Custom status key",
+ ),
+ ),
+ migrations.AddField(
+ model_name="returnorder",
+ name="status_custom_key",
+ field=generic.states.fields.ExtraInvenTreeCustomStatusModelField(
+ blank=True,
+ default=None,
+ help_text="Additional status information for this item",
+ null=True,
+ verbose_name="Custom status key",
+ ),
+ ),
+ migrations.AddField(
+ model_name="returnorderlineitem",
+ name="outcome_custom_key",
+ field=generic.states.fields.ExtraInvenTreeCustomStatusModelField(
+ blank=True,
+ default=None,
+ help_text="Additional status information for this item",
+ null=True,
+ verbose_name="Custom status key",
+ ),
+ ),
+ migrations.AddField(
+ model_name="salesorder",
+ name="status_custom_key",
+ field=generic.states.fields.ExtraInvenTreeCustomStatusModelField(
+ blank=True,
+ default=None,
+ help_text="Additional status information for this item",
+ null=True,
+ verbose_name="Custom status key",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="purchaseorder",
+ name="status",
+ field=generic.states.fields.InvenTreeCustomStatusModelField(
+ choices=InvenTree.status_codes.PurchaseOrderStatus.items(),
+ default=10,
+ help_text="Purchase order status",
+ verbose_name="Status",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="returnorder",
+ name="status",
+ field=generic.states.fields.InvenTreeCustomStatusModelField(
+ choices=InvenTree.status_codes.ReturnOrderStatus.items(),
+ default=10,
+ help_text="Return order status",
+ verbose_name="Status",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="returnorderlineitem",
+ name="outcome",
+ field=generic.states.fields.InvenTreeCustomStatusModelField(
+ choices=InvenTree.status_codes.ReturnOrderLineStatus.items(),
+ default=10,
+ help_text="Outcome for this line item",
+ verbose_name="Outcome",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="salesorder",
+ name="status",
+ field=generic.states.fields.InvenTreeCustomStatusModelField(
+ choices=InvenTree.status_codes.SalesOrderStatus.items(),
+ default=10,
+ help_text="Sales order status",
+ verbose_name="Status",
+ ),
+ ),
+ ]
diff --git a/src/backend/InvenTree/order/models.py b/src/backend/InvenTree/order/models.py
index 1a8124cf5c70..88b3325da690 100644
--- a/src/backend/InvenTree/order/models.py
+++ b/src/backend/InvenTree/order/models.py
@@ -37,6 +37,7 @@
from common.settings import get_global_setting
from company.models import Address, Company, Contact, SupplierPart
from generic.states import StateTransitionMixin
+from generic.states.fields import InvenTreeCustomStatusModelField
from InvenTree.exceptions import log_error
from InvenTree.fields import (
InvenTreeModelMoneyField,
@@ -470,7 +471,7 @@ def __str__(self):
validators=[order.validators.validate_purchase_order_reference],
)
- status = models.PositiveIntegerField(
+ status = InvenTreeCustomStatusModelField(
default=PurchaseOrderStatus.PENDING.value,
choices=PurchaseOrderStatus.items(),
verbose_name=_('Status'),
@@ -996,7 +997,7 @@ def company(self):
"""Accessor helper for Order base."""
return self.customer
- status = models.PositiveIntegerField(
+ status = InvenTreeCustomStatusModelField(
default=SalesOrderStatus.PENDING.value,
choices=SalesOrderStatus.items(),
verbose_name=_('Status'),
@@ -2153,7 +2154,7 @@ def company(self):
"""Accessor helper for Order base class."""
return self.customer
- status = models.PositiveIntegerField(
+ status = InvenTreeCustomStatusModelField(
default=ReturnOrderStatus.PENDING.value,
choices=ReturnOrderStatus.items(),
verbose_name=_('Status'),
@@ -2404,7 +2405,7 @@ def received(self):
"""Return True if this item has been received."""
return self.received_date is not None
- outcome = models.PositiveIntegerField(
+ outcome = InvenTreeCustomStatusModelField(
default=ReturnOrderLineStatus.PENDING.value,
choices=ReturnOrderLineStatus.items(),
verbose_name=_('Outcome'),
diff --git a/src/backend/InvenTree/order/serializers.py b/src/backend/InvenTree/order/serializers.py
index ebfd0bd8f594..d0d07c14d83d 100644
--- a/src/backend/InvenTree/order/serializers.py
+++ b/src/backend/InvenTree/order/serializers.py
@@ -32,6 +32,7 @@
ContactSerializer,
SupplierPartSerializer,
)
+from generic.states.fields import InvenTreeCustomStatusSerializerMixin
from importer.mixins import DataImportExportSerializerMixin
from importer.registry import register_importer
from InvenTree.helpers import (
@@ -161,6 +162,7 @@ def order_fields(extra_fields):
'address_detail',
'status',
'status_text',
+ 'status_custom_key',
'notes',
'barcode_hash',
'overdue',
@@ -216,7 +218,11 @@ class AbstractExtraLineMeta:
@register_importer()
class PurchaseOrderSerializer(
- NotesFieldMixin, TotalPriceMixin, AbstractOrderSerializer, InvenTreeModelSerializer
+ NotesFieldMixin,
+ TotalPriceMixin,
+ InvenTreeCustomStatusSerializerMixin,
+ AbstractOrderSerializer,
+ InvenTreeModelSerializer,
):
"""Serializer for a PurchaseOrder object."""
@@ -859,7 +865,11 @@ def save(self):
@register_importer()
class SalesOrderSerializer(
- NotesFieldMixin, TotalPriceMixin, AbstractOrderSerializer, InvenTreeModelSerializer
+ NotesFieldMixin,
+ TotalPriceMixin,
+ InvenTreeCustomStatusSerializerMixin,
+ AbstractOrderSerializer,
+ InvenTreeModelSerializer,
):
"""Serializer for the SalesOrder model class."""
@@ -1642,7 +1652,11 @@ class Meta(AbstractExtraLineMeta):
@register_importer()
class ReturnOrderSerializer(
- NotesFieldMixin, AbstractOrderSerializer, TotalPriceMixin, InvenTreeModelSerializer
+ NotesFieldMixin,
+ InvenTreeCustomStatusSerializerMixin,
+ AbstractOrderSerializer,
+ TotalPriceMixin,
+ InvenTreeModelSerializer,
):
"""Serializer for the ReturnOrder model class."""
diff --git a/src/backend/InvenTree/order/status_codes.py b/src/backend/InvenTree/order/status_codes.py
index bc5df2bca337..3dcbee01f518 100644
--- a/src/backend/InvenTree/order/status_codes.py
+++ b/src/backend/InvenTree/order/status_codes.py
@@ -2,20 +2,20 @@
from django.utils.translation import gettext_lazy as _
-from generic.states import StatusCode
+from generic.states import ColorEnum, StatusCode
class PurchaseOrderStatus(StatusCode):
"""Defines a set of status codes for a PurchaseOrder."""
# Order status codes
- PENDING = 10, _('Pending'), 'secondary' # Order is pending (not yet placed)
- PLACED = 20, _('Placed'), 'primary' # Order has been placed with supplier
- ON_HOLD = 25, _('On Hold'), 'warning' # Order is on hold
- COMPLETE = 30, _('Complete'), 'success' # Order has been completed
- CANCELLED = 40, _('Cancelled'), 'danger' # Order was cancelled
- LOST = 50, _('Lost'), 'warning' # Order was lost
- RETURNED = 60, _('Returned'), 'warning' # Order was returned
+ PENDING = 10, _('Pending'), ColorEnum.secondary # Order is pending (not yet placed)
+ PLACED = 20, _('Placed'), ColorEnum.primary # Order has been placed with supplier
+ ON_HOLD = 25, _('On Hold'), ColorEnum.warning # Order is on hold
+ COMPLETE = 30, _('Complete'), ColorEnum.success # Order has been completed
+ CANCELLED = 40, _('Cancelled'), ColorEnum.danger # Order was cancelled
+ LOST = 50, _('Lost'), ColorEnum.warning # Order was lost
+ RETURNED = 60, _('Returned'), ColorEnum.warning # Order was returned
class PurchaseOrderStatusGroups:
@@ -39,18 +39,18 @@ class PurchaseOrderStatusGroups:
class SalesOrderStatus(StatusCode):
"""Defines a set of status codes for a SalesOrder."""
- PENDING = 10, _('Pending'), 'secondary' # Order is pending
+ PENDING = 10, _('Pending'), ColorEnum.secondary # Order is pending
IN_PROGRESS = (
15,
_('In Progress'),
- 'primary',
+ ColorEnum.primary,
) # Order has been issued, and is in progress
- SHIPPED = 20, _('Shipped'), 'success' # Order has been shipped to customer
- ON_HOLD = 25, _('On Hold'), 'warning' # Order is on hold
- COMPLETE = 30, _('Complete'), 'success' # Order is complete
- CANCELLED = 40, _('Cancelled'), 'danger' # Order has been cancelled
- LOST = 50, _('Lost'), 'warning' # Order was lost
- RETURNED = 60, _('Returned'), 'warning' # Order was returned
+ SHIPPED = 20, _('Shipped'), ColorEnum.success # Order has been shipped to customer
+ ON_HOLD = 25, _('On Hold'), ColorEnum.warning # Order is on hold
+ COMPLETE = 30, _('Complete'), ColorEnum.success # Order is complete
+ CANCELLED = 40, _('Cancelled'), ColorEnum.danger # Order has been cancelled
+ LOST = 50, _('Lost'), ColorEnum.warning # Order was lost
+ RETURNED = 60, _('Returned'), ColorEnum.warning # Order was returned
class SalesOrderStatusGroups:
@@ -71,15 +71,15 @@ class ReturnOrderStatus(StatusCode):
"""Defines a set of status codes for a ReturnOrder."""
# Order is pending, waiting for receipt of items
- PENDING = 10, _('Pending'), 'secondary'
+ PENDING = 10, _('Pending'), ColorEnum.secondary
# Items have been received, and are being inspected
- IN_PROGRESS = 20, _('In Progress'), 'primary'
+ IN_PROGRESS = 20, _('In Progress'), ColorEnum.primary
- ON_HOLD = 25, _('On Hold'), 'warning'
+ ON_HOLD = 25, _('On Hold'), ColorEnum.warning
- COMPLETE = 30, _('Complete'), 'success'
- CANCELLED = 40, _('Cancelled'), 'danger'
+ COMPLETE = 30, _('Complete'), ColorEnum.success
+ CANCELLED = 40, _('Cancelled'), ColorEnum.danger
class ReturnOrderStatusGroups:
@@ -95,19 +95,19 @@ class ReturnOrderStatusGroups:
class ReturnOrderLineStatus(StatusCode):
"""Defines a set of status codes for a ReturnOrderLineItem."""
- PENDING = 10, _('Pending'), 'secondary'
+ PENDING = 10, _('Pending'), ColorEnum.secondary
# Item is to be returned to customer, no other action
- RETURN = 20, _('Return'), 'success'
+ RETURN = 20, _('Return'), ColorEnum.success
# Item is to be repaired, and returned to customer
- REPAIR = 30, _('Repair'), 'primary'
+ REPAIR = 30, _('Repair'), ColorEnum.primary
# Item is to be replaced (new item shipped)
- REPLACE = 40, _('Replace'), 'warning'
+ REPLACE = 40, _('Replace'), ColorEnum.warning
# Item is to be refunded (cannot be repaired)
- REFUND = 50, _('Refund'), 'info'
+ REFUND = 50, _('Refund'), ColorEnum.info
# Item is rejected
- REJECT = 60, _('Reject'), 'danger'
+ REJECT = 60, _('Reject'), ColorEnum.danger
diff --git a/src/backend/InvenTree/order/templates/order/order_base.html b/src/backend/InvenTree/order/templates/order/order_base.html
index b02aafa19b52..77e044120b08 100644
--- a/src/backend/InvenTree/order/templates/order/order_base.html
+++ b/src/backend/InvenTree/order/templates/order/order_base.html
@@ -122,7 +122,7 @@
|
{% trans "Order Status" %} |
- {% status_label 'purchase_order' order.status %}
+ {% display_status_label 'purchase_order' order.status_custom_key order.status %}
{% if order.is_overdue %}
{% trans "Overdue" %}
{% endif %}
diff --git a/src/backend/InvenTree/order/templates/order/return_order_base.html b/src/backend/InvenTree/order/templates/order/return_order_base.html
index 494701abf19b..15755c4199d4 100644
--- a/src/backend/InvenTree/order/templates/order/return_order_base.html
+++ b/src/backend/InvenTree/order/templates/order/return_order_base.html
@@ -115,7 +115,7 @@
| |
{% trans "Order Status" %} |
- {% status_label 'return_order' order.status %}
+ {% display_status_label 'return_order' order.status_custom_key order.status %}
{% if order.is_overdue %}
{% trans "Overdue" %}
{% endif %}
diff --git a/src/backend/InvenTree/order/templates/order/sales_order_base.html b/src/backend/InvenTree/order/templates/order/sales_order_base.html
index c8d0179aa1bf..987b2e49d2fd 100644
--- a/src/backend/InvenTree/order/templates/order/sales_order_base.html
+++ b/src/backend/InvenTree/order/templates/order/sales_order_base.html
@@ -124,7 +124,7 @@
| |
{% trans "Order Status" %} |
- {% status_label 'sales_order' order.status %}
+ {% display_status_label 'sales_order' order.status_custom_key order.status %}
{% if order.is_overdue %}
{% trans "Overdue" %}
{% endif %}
diff --git a/src/backend/InvenTree/plugin/base/label/test_label_mixin.py b/src/backend/InvenTree/plugin/base/label/test_label_mixin.py
index 5845163e6334..c95fb4036bc6 100644
--- a/src/backend/InvenTree/plugin/base/label/test_label_mixin.py
+++ b/src/backend/InvenTree/plugin/base/label/test_label_mixin.py
@@ -125,6 +125,7 @@ def test_printing_process(self):
self.assertGreater(len(plugins), 0)
plugin = registry.get_plugin('samplelabelprinter')
+ self.assertIsNotNone(plugin)
config = plugin.plugin_config()
# Ensure that the plugin is not active
diff --git a/src/backend/InvenTree/report/tests.py b/src/backend/InvenTree/report/tests.py
index 8f05fa0041ca..e534a3ab3946 100644
--- a/src/backend/InvenTree/report/tests.py
+++ b/src/backend/InvenTree/report/tests.py
@@ -491,7 +491,10 @@ class PrintTestMixins:
def do_activate_plugin(self):
"""Activate the 'samplelabel' plugin."""
- config = registry.get_plugin(self.plugin_ref).plugin_config()
+ plugin = registry.get_plugin(self.plugin_ref)
+ self.assertIsNotNone(plugin)
+ config = plugin.plugin_config()
+ self.assertIsNotNone(config)
config.active = True
config.save()
diff --git a/src/backend/InvenTree/stock/admin.py b/src/backend/InvenTree/stock/admin.py
index ced26f60fae7..cd1bffab79e9 100644
--- a/src/backend/InvenTree/stock/admin.py
+++ b/src/backend/InvenTree/stock/admin.py
@@ -142,6 +142,7 @@ class Meta:
'barcode_hash',
'barcode_data',
'owner',
+ 'status_custom_key',
]
id = Field(
diff --git a/src/backend/InvenTree/stock/migrations/0113_stockitem_status_custom_key_and_more.py b/src/backend/InvenTree/stock/migrations/0113_stockitem_status_custom_key_and_more.py
new file mode 100644
index 000000000000..5b9bc7e8d6eb
--- /dev/null
+++ b/src/backend/InvenTree/stock/migrations/0113_stockitem_status_custom_key_and_more.py
@@ -0,0 +1,38 @@
+# Generated by Django 4.2.14 on 2024-08-07 22:40
+
+import django.core.validators
+from django.db import migrations
+
+import generic.states
+import generic.states.fields
+import InvenTree.status_codes
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("stock", "0112_alter_stocklocation_custom_icon_and_more"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="stockitem",
+ name="status_custom_key",
+ field=generic.states.fields.ExtraInvenTreeCustomStatusModelField(
+ blank=True,
+ default=None,
+ help_text="Additional status information for this item",
+ null=True,
+ verbose_name="Custom status key",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="stockitem",
+ name="status",
+ field=generic.states.fields.InvenTreeCustomStatusModelField(
+ choices=InvenTree.status_codes.StockStatus.items(),
+ default=10,
+ validators=[django.core.validators.MinValueValidator(0)],
+ ),
+ ),
+ ]
diff --git a/src/backend/InvenTree/stock/models.py b/src/backend/InvenTree/stock/models.py
index 3008f77970b3..e0482795d524 100644
--- a/src/backend/InvenTree/stock/models.py
+++ b/src/backend/InvenTree/stock/models.py
@@ -37,13 +37,18 @@
from common.icons import validate_icon
from common.settings import get_global_setting
from company import models as CompanyModels
+from generic.states.fields import InvenTreeCustomStatusModelField
from InvenTree.fields import InvenTreeModelMoneyField, InvenTreeURLField
-from order.status_codes import SalesOrderStatusGroups
+from InvenTree.status_codes import (
+ SalesOrderStatusGroups,
+ StockHistoryCode,
+ StockStatus,
+ StockStatusGroups,
+)
from part import models as PartModels
from plugin.events import trigger_event
from stock import models as StockModels
from stock.generators import generate_batch_code
-from stock.status_codes import StockHistoryCode, StockStatus, StockStatusGroups
from users.models import Owner
logger = logging.getLogger('inventree')
@@ -940,7 +945,7 @@ def get_part_name(self):
help_text=_('Delete this Stock Item when stock is depleted'),
)
- status = models.PositiveIntegerField(
+ status = InvenTreeCustomStatusModelField(
default=StockStatus.OK.value,
choices=StockStatus.items(),
validators=[MinValueValidator(0)],
diff --git a/src/backend/InvenTree/stock/serializers.py b/src/backend/InvenTree/stock/serializers.py
index 8f83b59751e1..58ed3a247181 100644
--- a/src/backend/InvenTree/stock/serializers.py
+++ b/src/backend/InvenTree/stock/serializers.py
@@ -27,6 +27,7 @@
import stock.filters
import stock.status_codes
from common.settings import get_global_setting
+from generic.states.fields import InvenTreeCustomStatusSerializerMixin
from importer.mixins import DataImportExportSerializerMixin
from importer.registry import register_importer
from InvenTree.serializers import InvenTreeCurrencySerializer, InvenTreeDecimalField
@@ -326,7 +327,9 @@ def validate_serial(self, value):
@register_importer()
class StockItemSerializer(
- DataImportExportSerializerMixin, InvenTree.serializers.InvenTreeTagModelSerializer
+ DataImportExportSerializerMixin,
+ InvenTreeCustomStatusSerializerMixin,
+ InvenTree.serializers.InvenTreeTagModelSerializer,
):
"""Serializer for a StockItem.
@@ -373,6 +376,7 @@ class Meta:
'serial',
'status',
'status_text',
+ 'status_custom_key',
'stocktake_date',
'supplier_part',
'sku',
diff --git a/src/backend/InvenTree/stock/status_codes.py b/src/backend/InvenTree/stock/status_codes.py
index 3c646bc45566..d59d6225694a 100644
--- a/src/backend/InvenTree/stock/status_codes.py
+++ b/src/backend/InvenTree/stock/status_codes.py
@@ -2,24 +2,28 @@
from django.utils.translation import gettext_lazy as _
-from generic.states import StatusCode
+from generic.states import ColorEnum, StatusCode
class StockStatus(StatusCode):
"""Status codes for Stock."""
- OK = 10, _('OK'), 'success' # Item is OK
- ATTENTION = 50, _('Attention needed'), 'warning' # Item requires attention
- DAMAGED = 55, _('Damaged'), 'warning' # Item is damaged
- DESTROYED = 60, _('Destroyed'), 'danger' # Item is destroyed
- REJECTED = 65, _('Rejected'), 'danger' # Item is rejected
- LOST = 70, _('Lost'), 'dark' # Item has been lost
+ OK = 10, _('OK'), ColorEnum.success # Item is OK
+ ATTENTION = 50, _('Attention needed'), ColorEnum.warning # Item requires attention
+ DAMAGED = 55, _('Damaged'), ColorEnum.warning # Item is damaged
+ DESTROYED = 60, _('Destroyed'), ColorEnum.danger # Item is destroyed
+ REJECTED = 65, _('Rejected'), ColorEnum.danger # Item is rejected
+ LOST = 70, _('Lost'), ColorEnum.dark # Item has been lost
QUARANTINED = (
75,
_('Quarantined'),
- 'info',
+ ColorEnum.info,
) # Item has been quarantined and is unavailable
- RETURNED = 85, _('Returned'), 'warning' # Item has been returned from a customer
+ RETURNED = (
+ 85,
+ _('Returned'),
+ ColorEnum.warning,
+ ) # Item has been returned from a customer
class StockStatusGroups:
diff --git a/src/backend/InvenTree/stock/templates/stock/item_base.html b/src/backend/InvenTree/stock/templates/stock/item_base.html
index fac0adf14daf..8550bc9419ba 100644
--- a/src/backend/InvenTree/stock/templates/stock/item_base.html
+++ b/src/backend/InvenTree/stock/templates/stock/item_base.html
@@ -425,7 +425,7 @@ {% if item.quantity != available %}{% decimal available %} / {% endif %}{% d
|
{% trans "Status" %} |
- {% status_label 'stock' item.status %} |
+ {% display_status_label 'stock' item.status_custom_key item.status %} |
{% if item.expiry_date %}
diff --git a/src/backend/InvenTree/stock/test_api.py b/src/backend/InvenTree/stock/test_api.py
index 61a8a43ba73d..af73fa1b73d3 100644
--- a/src/backend/InvenTree/stock/test_api.py
+++ b/src/backend/InvenTree/stock/test_api.py
@@ -7,6 +7,7 @@
from enum import IntEnum
import django.http
+from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.urls import reverse
@@ -17,7 +18,7 @@
import build.models
import company.models
import part.models
-from common.models import InvenTreeSetting
+from common.models import InvenTreeCustomUserStateModel, InvenTreeSetting
from InvenTree.unit_test import InvenTreeAPITestCase
from part.models import Part, PartTestTemplate
from stock.models import (
@@ -925,6 +926,100 @@ def test_query_count(self):
)
+class CustomStockItemStatusTest(StockAPITestCase):
+ """Tests for custom stock item statuses."""
+
+ list_url = reverse('api-stock-list')
+
+ def setUp(self):
+ """Setup for all tests."""
+ super().setUp()
+ self.status = InvenTreeCustomUserStateModel.objects.create(
+ key=11,
+ name='OK - advanced',
+ label='OK - adv.',
+ color='secondary',
+ logical_key=10,
+ model=ContentType.objects.get(model='stockitem'),
+ reference_status='StockStatus',
+ )
+ self.status2 = InvenTreeCustomUserStateModel.objects.create(
+ key=51,
+ name='attention 2',
+ label='attention 2',
+ color='secondary',
+ logical_key=50,
+ model=ContentType.objects.get(model='stockitem'),
+ reference_status='StockStatus',
+ )
+
+ def test_custom_status(self):
+ """Tests interaction with states."""
+ # Create a stock item with the custom status code via the API
+ response = self.post(
+ self.list_url,
+ {
+ 'name': 'Test Type 1',
+ 'description': 'Test desc 1',
+ 'quantity': 1,
+ 'part': 1,
+ 'status_custom_key': self.status.key,
+ },
+ expected_code=201,
+ )
+ self.assertEqual(response.data['status'], self.status.logical_key)
+ self.assertEqual(response.data['status_custom_key'], self.status.key)
+ pk = response.data['pk']
+
+ # Update the stock item with another custom status code via the API
+ response = self.patch(
+ reverse('api-stock-detail', kwargs={'pk': pk}),
+ {'status_custom_key': self.status2.key},
+ expected_code=200,
+ )
+ self.assertEqual(response.data['status'], self.status2.logical_key)
+ self.assertEqual(response.data['status_custom_key'], self.status2.key)
+
+ # Try if status_custom_key is rewrite with status bying set
+ response = self.patch(
+ reverse('api-stock-detail', kwargs={'pk': pk}),
+ {'status': self.status.logical_key},
+ expected_code=200,
+ )
+ self.assertEqual(response.data['status'], self.status.logical_key)
+ self.assertEqual(response.data['status_custom_key'], self.status.logical_key)
+
+ # Create a stock item with a normal status code via the API
+ response = self.post(
+ self.list_url,
+ {
+ 'name': 'Test Type 1',
+ 'description': 'Test desc 1',
+ 'quantity': 1,
+ 'part': 1,
+ 'status_key': self.status.key,
+ },
+ expected_code=201,
+ )
+ self.assertEqual(response.data['status'], self.status.logical_key)
+ self.assertEqual(response.data['status_custom_key'], self.status.logical_key)
+
+ def test_options(self):
+ """Test the StockItem OPTIONS endpoint to contain custom StockStatuses."""
+ response = self.options(self.list_url)
+
+ self.assertEqual(response.status_code, 200)
+
+ # Check that the response contains the custom StockStatuses
+ actions = response.data['actions']['POST']
+ self.assertIn('status_custom_key', actions)
+ status_custom_key = actions['status_custom_key']
+ self.assertEqual(len(status_custom_key['choices']), 10)
+ status = status_custom_key['choices'][1]
+ self.assertEqual(status['value'], self.status.key)
+ self.assertEqual(status['display_name'], self.status.label)
+
+
class StockItemTest(StockAPITestCase):
"""Series of API tests for the StockItem API."""
diff --git a/src/backend/InvenTree/templates/js/translated/build.js b/src/backend/InvenTree/templates/js/translated/build.js
index ba5b7d58a4bc..58792bd47921 100644
--- a/src/backend/InvenTree/templates/js/translated/build.js
+++ b/src/backend/InvenTree/templates/js/translated/build.js
@@ -615,7 +615,7 @@ function completeBuildOutputs(build_id, outputs, options={}) {
method: 'POST',
preFormContent: html,
fields: {
- status: {},
+ status_custom_key: {},
location: {
filters: {
structural: false,
@@ -644,7 +644,7 @@ function completeBuildOutputs(build_id, outputs, options={}) {
// Extract data elements from the form
var data = {
outputs: [],
- status: getFormFieldValue('status', {}, opts),
+ status_custom_key: getFormFieldValue('status_custom_key', {}, opts),
location: getFormFieldValue('location', {}, opts),
notes: getFormFieldValue('notes', {}, opts),
accept_incomplete_allocation: getFormFieldValue('accept_incomplete_allocation', {type: 'boolean'}, opts),
@@ -1153,7 +1153,7 @@ function loadBuildOrderAllocationTable(table, options={}) {
if (row.build_detail) {
html += `- ${row.build_detail.title}`;
- html += buildStatusDisplay(row.build_detail.status, {
+ html += buildStatusDisplay(row.build_detail.status_custom_key, {
classes: 'float-right',
});
}
@@ -1556,7 +1556,7 @@ function loadBuildOutputTable(build_info, options={}) {
text += ` ({% trans "Batch" %}: ${row.batch})`;
}
- text += stockStatusDisplay(row.status, {classes: 'float-right'});
+ text += stockStatusDisplay(row.status_custom_key, {classes: 'float-right'});
return text;
}
@@ -2362,7 +2362,7 @@ function loadBuildTable(table, options) {
}
},
{
- field: 'status',
+ field: 'status_custom_key',
title: '{% trans "Status" %}',
sortable: true,
formatter: function(value) {
diff --git a/src/backend/InvenTree/templates/js/translated/part.js b/src/backend/InvenTree/templates/js/translated/part.js
index 1407f7644a3a..9080b6b27d4f 100644
--- a/src/backend/InvenTree/templates/js/translated/part.js
+++ b/src/backend/InvenTree/templates/js/translated/part.js
@@ -1761,7 +1761,7 @@ function loadPartPurchaseOrderTable(table, part_id, options={}) {
var html = renderLink(order.reference, `/order/purchase-order/${order.pk}/`);
html += purchaseOrderStatusDisplay(
- order.status,
+ order.status_custom_key,
{
classes: 'float-right',
}
diff --git a/src/backend/InvenTree/templates/js/translated/purchase_order.js b/src/backend/InvenTree/templates/js/translated/purchase_order.js
index 33f3f24f2b34..d99223256c59 100644
--- a/src/backend/InvenTree/templates/js/translated/purchase_order.js
+++ b/src/backend/InvenTree/templates/js/translated/purchase_order.js
@@ -1788,12 +1788,12 @@ function loadPurchaseOrderTable(table, options) {
}
},
{
- field: 'status',
+ field: 'status_custom_key',
title: '{% trans "Status" %}',
switchable: true,
sortable: true,
formatter: function(value, row) {
- return purchaseOrderStatusDisplay(row.status);
+ return purchaseOrderStatusDisplay(row.status_custom_key);
}
},
{
diff --git a/src/backend/InvenTree/templates/js/translated/return_order.js b/src/backend/InvenTree/templates/js/translated/return_order.js
index c22330c87dc7..57ac61018536 100644
--- a/src/backend/InvenTree/templates/js/translated/return_order.js
+++ b/src/backend/InvenTree/templates/js/translated/return_order.js
@@ -326,10 +326,10 @@ function loadReturnOrderTable(table, options={}) {
},
{
sortable: true,
- field: 'status',
+ field: 'status_custom_key',
title: '{% trans "Status" %}',
formatter: function(value, row) {
- return returnOrderStatusDisplay(row.status);
+ return returnOrderStatusDisplay(row.status_custom_key);
}
},
{
diff --git a/src/backend/InvenTree/templates/js/translated/sales_order.js b/src/backend/InvenTree/templates/js/translated/sales_order.js
index 358c91b2ae93..542330156d14 100644
--- a/src/backend/InvenTree/templates/js/translated/sales_order.js
+++ b/src/backend/InvenTree/templates/js/translated/sales_order.js
@@ -851,10 +851,10 @@ function loadSalesOrderTable(table, options) {
},
{
sortable: true,
- field: 'status',
+ field: 'status_custom_key',
title: '{% trans "Status" %}',
formatter: function(value, row) {
- return salesOrderStatusDisplay(row.status);
+ return salesOrderStatusDisplay(row.status_custom_key);
}
},
{
diff --git a/src/backend/InvenTree/templates/js/translated/stock.js b/src/backend/InvenTree/templates/js/translated/stock.js
index f9c5c2b3e367..6c2153634308 100644
--- a/src/backend/InvenTree/templates/js/translated/stock.js
+++ b/src/backend/InvenTree/templates/js/translated/stock.js
@@ -380,7 +380,7 @@ function stockItemFields(options={}) {
batch: {
icon: 'fa-layer-group',
},
- status: {},
+ status_custom_key: {},
expiry_date: {
icon: 'fa-calendar-alt',
},
@@ -698,7 +698,7 @@ function assignStockToCustomer(items, options={}) {
var thumbnail = thumbnailImage(part.thumbnail || part.image);
- var status = stockStatusDisplay(item.status, {classes: 'float-right'});
+ var status = stockStatusDisplay(item.status_custom_key, {classes: 'float-right'});
var quantity = '';
@@ -879,7 +879,7 @@ function mergeStockItems(items, options={}) {
quantity = `{% trans "Quantity" %}: ${item.quantity}`;
}
- quantity += stockStatusDisplay(item.status, {classes: 'float-right'});
+ quantity += stockStatusDisplay(item.status_custom_key, {classes: 'float-right'});
let buttons = wrapButtons(
makeIconButton(
@@ -1113,7 +1113,7 @@ function adjustStock(action, items, options={}) {
var thumb = thumbnailImage(item.part_detail.thumbnail || item.part_detail.image);
- var status = stockStatusDisplay(item.status, {
+ var status = stockStatusDisplay(item.status_custom_key, {
classes: 'float-right'
});
@@ -1922,7 +1922,8 @@ function makeStockActions(table) {
}
},
{
- label: 'status',
+
+ label: 'status_custom_key',
icon: 'fa-info-circle icon-blue',
title: '{% trans "Change stock status" %}',
permission: 'stock.change',
@@ -2257,7 +2258,7 @@ function loadStockTable(table, options) {
columns.push(col);
col = {
- field: 'status',
+ field: 'status_custom_key',
title: '{% trans "Status" %}',
formatter: function(value) {
return stockStatusDisplay(value);
@@ -3075,11 +3076,11 @@ function loadStockTrackingTable(table, options) {
}
// Status information
- if (details.status) {
+ if (details.status_custom_key) {
html += ` {% trans "Status" %}`;
html += ' | ';
- html += stockStatusDisplay(details.status);
+ html += stockStatusDisplay(details.status_custom_key);
html += ' | ';
}
@@ -3200,7 +3201,7 @@ function loadInstalledInTable(table, options) {
}
},
{
- field: 'status',
+ field: 'status_custom_key',
title: '{% trans "Status" %}',
formatter: function(value) {
return stockStatusDisplay(value);
@@ -3401,7 +3402,7 @@ function setStockStatus(items, options={}) {
method: 'POST',
preFormContent: html,
fields: {
- status: {},
+ status_custom_key: {},
note: {},
},
processBeforeUpload: function(data) {
diff --git a/src/backend/InvenTree/users/models.py b/src/backend/InvenTree/users/models.py
index 1fcfc9d901c9..cb4af4c59202 100644
--- a/src/backend/InvenTree/users/models.py
+++ b/src/backend/InvenTree/users/models.py
@@ -345,6 +345,7 @@ def get_ruleset_ignore():
'common_projectcode',
'common_webhookendpoint',
'common_webhookmessage',
+ 'common_inventreecustomuserstatemodel',
'users_owner',
# Third-party tables
'error_report_error',
diff --git a/src/frontend/src/components/render/Build.tsx b/src/frontend/src/components/render/Build.tsx
index 90ddbf6cc352..5a8687ba7e7c 100644
--- a/src/frontend/src/components/render/Build.tsx
+++ b/src/frontend/src/components/render/Build.tsx
@@ -19,7 +19,7 @@ export function RenderBuildOrder(
primary={instance.reference}
secondary={instance.title}
suffix={StatusRenderer({
- status: instance.status,
+ status: instance.status_custom_key,
type: ModelType.build
})}
image={instance.part_detail?.thumbnail || instance.part_detail?.image}
@@ -39,7 +39,7 @@ export function RenderBuildLine({
primary={instance.part_detail.full_name}
secondary={instance.quantity}
suffix={StatusRenderer({
- status: instance.status,
+ status: instance.status_custom_key,
type: ModelType.build
})}
image={instance.part_detail.thumbnail || instance.part_detail.image}
diff --git a/src/frontend/src/components/render/Generic.tsx b/src/frontend/src/components/render/Generic.tsx
index e201ee1fc094..3d04f990df6f 100644
--- a/src/frontend/src/components/render/Generic.tsx
+++ b/src/frontend/src/components/render/Generic.tsx
@@ -15,6 +15,12 @@ export function RenderProjectCode({
);
}
+export function RenderContentType({
+ instance
+}: Readonly): ReactNode {
+ return instance && ;
+}
+
export function RenderImportSession({
instance
}: {
diff --git a/src/frontend/src/components/render/Instance.tsx b/src/frontend/src/components/render/Instance.tsx
index 5938eb34ef75..c2df42a7d3e6 100644
--- a/src/frontend/src/components/render/Instance.tsx
+++ b/src/frontend/src/components/render/Instance.tsx
@@ -16,7 +16,11 @@ import {
RenderManufacturerPart,
RenderSupplierPart
} from './Company';
-import { RenderImportSession, RenderProjectCode } from './Generic';
+import {
+ RenderContentType,
+ RenderImportSession,
+ RenderProjectCode
+} from './Generic';
import { ModelInformationDict } from './ModelType';
import {
RenderPurchaseOrder,
@@ -87,7 +91,8 @@ const RendererLookup: EnumDictionary<
[ModelType.importsession]: RenderImportSession,
[ModelType.reporttemplate]: RenderReportTemplate,
[ModelType.labeltemplate]: RenderLabelTemplate,
- [ModelType.pluginconfig]: RenderPlugin
+ [ModelType.pluginconfig]: RenderPlugin,
+ [ModelType.contenttype]: RenderContentType
};
export type RenderInstanceProps = {
diff --git a/src/frontend/src/components/render/ModelType.tsx b/src/frontend/src/components/render/ModelType.tsx
index ef30cfa0c214..20b5fa1bb7fe 100644
--- a/src/frontend/src/components/render/ModelType.tsx
+++ b/src/frontend/src/components/render/ModelType.tsx
@@ -241,6 +241,11 @@ export const ModelInformationDict: ModelDict = {
url_overview: '/pluginconfig',
url_detail: '/pluginconfig/:pk/',
api_endpoint: ApiEndpoints.plugin_list
+ },
+ contenttype: {
+ label: t`Content Type`,
+ label_multiple: t`Content Types`,
+ api_endpoint: ApiEndpoints.content_type_list
}
};
diff --git a/src/frontend/src/components/render/Order.tsx b/src/frontend/src/components/render/Order.tsx
index 416f45f1fdcf..72f5544f4f4e 100644
--- a/src/frontend/src/components/render/Order.tsx
+++ b/src/frontend/src/components/render/Order.tsx
@@ -21,7 +21,7 @@ export function RenderPurchaseOrder(
primary={instance.reference}
secondary={instance.description}
suffix={StatusRenderer({
- status: instance.status,
+ status: instance.status_custom_key,
type: ModelType.purchaseorder
})}
image={supplier.thumnbnail || supplier.image}
@@ -49,7 +49,7 @@ export function RenderReturnOrder(
primary={instance.reference}
secondary={instance.description}
suffix={StatusRenderer({
- status: instance.status,
+ status: instance.status_custom_key,
type: ModelType.returnorder
})}
image={customer.thumnbnail || customer.image}
@@ -94,7 +94,7 @@ export function RenderSalesOrder(
primary={instance.reference}
secondary={instance.description}
suffix={StatusRenderer({
- status: instance.status,
+ status: instance.status_custom_key,
type: ModelType.salesorder
})}
image={customer.thumnbnail || customer.image}
diff --git a/src/frontend/src/enums/ApiEndpoints.tsx b/src/frontend/src/enums/ApiEndpoints.tsx
index a7221d62b76d..740b73336e0d 100644
--- a/src/frontend/src/enums/ApiEndpoints.tsx
+++ b/src/frontend/src/enums/ApiEndpoints.tsx
@@ -42,11 +42,13 @@ export enum ApiEndpoints {
generate_barcode = 'barcode/generate/',
news = 'news/',
global_status = 'generic/status/',
+ custom_state_list = 'generic/status/custom/',
version = 'version/',
license = 'license/',
sso_providers = 'auth/providers/',
group_list = 'user/group/',
owner_list = 'user/owner/',
+ content_type_list = 'contenttype/',
icons = 'icons/',
// Data import endpoints
diff --git a/src/frontend/src/enums/ModelType.tsx b/src/frontend/src/enums/ModelType.tsx
index f20e3f1ec66e..15d36d2d3671 100644
--- a/src/frontend/src/enums/ModelType.tsx
+++ b/src/frontend/src/enums/ModelType.tsx
@@ -31,5 +31,6 @@ export enum ModelType {
group = 'group',
reporttemplate = 'reporttemplate',
labeltemplate = 'labeltemplate',
- pluginconfig = 'pluginconfig'
+ pluginconfig = 'pluginconfig',
+ contenttype = 'contenttype'
}
diff --git a/src/frontend/src/forms/BuildForms.tsx b/src/frontend/src/forms/BuildForms.tsx
index 1590c4a55b85..1b6a7301913e 100644
--- a/src/frontend/src/forms/BuildForms.tsx
+++ b/src/frontend/src/forms/BuildForms.tsx
@@ -301,7 +301,7 @@ export function useCompleteBuildOutputsForm({
};
})
},
- status: {},
+ status_custom_key: {},
location: {
filters: {
structural: false
diff --git a/src/frontend/src/forms/CommonForms.tsx b/src/frontend/src/forms/CommonForms.tsx
index 10ce0a082eff..40c060b92d28 100644
--- a/src/frontend/src/forms/CommonForms.tsx
+++ b/src/frontend/src/forms/CommonForms.tsx
@@ -12,6 +12,18 @@ export function projectCodeFields(): ApiFormFieldSet {
};
}
+export function customStateFields(): ApiFormFieldSet {
+ return {
+ key: {},
+ name: {},
+ label: {},
+ color: {},
+ logical_key: {},
+ model: {},
+ reference_status: {}
+ };
+}
+
export function customUnitsFields(): ApiFormFieldSet {
return {
name: {},
diff --git a/src/frontend/src/forms/StockForms.tsx b/src/frontend/src/forms/StockForms.tsx
index 868868057934..0577233192e0 100644
--- a/src/frontend/src/forms/StockForms.tsx
+++ b/src/frontend/src/forms/StockForms.tsx
@@ -138,7 +138,7 @@ export function useStockFields({
value: batchCode,
onValueChange: (value) => setBatchCode(value)
},
- status: {},
+ status_custom_key: {},
expiry_date: {
// TODO: icon
},
@@ -620,7 +620,7 @@ function stockChangeStatusFields(items: any[]): ApiFormFieldSet {
},
headers: [t`Part`, t`Location`, t`In Stock`, t`Actions`]
},
- status: {},
+ status_custom_key: {},
note: {}
};
diff --git a/src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx b/src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx
index 91bc1d898268..8f842613296e 100644
--- a/src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx
+++ b/src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx
@@ -68,6 +68,10 @@ const ProjectCodeTable = Loadable(
lazy(() => import('../../../../tables/settings/ProjectCodeTable'))
);
+const CustomStateTable = Loadable(
+ lazy(() => import('../../../../tables/settings/CustomStateTable'))
+);
+
const CustomUnitsTable = Loadable(
lazy(() => import('../../../../tables/settings/CustomUnitsTable'))
);
@@ -135,6 +139,12 @@ export default function AdminCenter() {
)
},
+ {
+ name: 'customstates',
+ label: t`Custom States`,
+ icon: ,
+ content:
+ },
{
name: 'customunits',
label: t`Custom Units`,
diff --git a/src/frontend/src/pages/build/BuildDetail.tsx b/src/frontend/src/pages/build/BuildDetail.tsx
index ff0f30a8c228..1348ff1e000e 100644
--- a/src/frontend/src/pages/build/BuildDetail.tsx
+++ b/src/frontend/src/pages/build/BuildDetail.tsx
@@ -526,7 +526,7 @@ export default function BuildDetail() {
? []
: [
diff --git a/src/frontend/src/pages/purchasing/PurchaseOrderDetail.tsx b/src/frontend/src/pages/purchasing/PurchaseOrderDetail.tsx
index a2eacb9d7bb2..f1a6076ccd47 100644
--- a/src/frontend/src/pages/purchasing/PurchaseOrderDetail.tsx
+++ b/src/frontend/src/pages/purchasing/PurchaseOrderDetail.tsx
@@ -459,7 +459,7 @@ export default function PurchaseOrderDetail() {
? []
: [
diff --git a/src/frontend/src/pages/sales/ReturnOrderDetail.tsx b/src/frontend/src/pages/sales/ReturnOrderDetail.tsx
index f623f3585715..2cfb1a47a5df 100644
--- a/src/frontend/src/pages/sales/ReturnOrderDetail.tsx
+++ b/src/frontend/src/pages/sales/ReturnOrderDetail.tsx
@@ -300,7 +300,7 @@ export default function ReturnOrderDetail() {
? []
: [
diff --git a/src/frontend/src/pages/sales/SalesOrderDetail.tsx b/src/frontend/src/pages/sales/SalesOrderDetail.tsx
index 73c917354596..e2306df852a2 100644
--- a/src/frontend/src/pages/sales/SalesOrderDetail.tsx
+++ b/src/frontend/src/pages/sales/SalesOrderDetail.tsx
@@ -498,7 +498,7 @@ export default function SalesOrderDetail() {
? []
: [
,
{
+ return [
+ {
+ accessor: 'name',
+ sortable: true
+ },
+ {
+ accessor: 'label',
+ 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
+ }
+ ];
+ }, []);
+
+ const newCustomState = useCreateApiFormModal({
+ url: ApiEndpoints.custom_state_list,
+ title: t`Add State`,
+ fields: customStateFields(),
+ table: table
+ });
+
+ const [selectedCustomState, setSelectedCustomState] = useState<
+ number | undefined
+ >(undefined);
+
+ const editCustomState = useEditApiFormModal({
+ url: ApiEndpoints.custom_state_list,
+ pk: selectedCustomState,
+ title: t`Edit State`,
+ fields: customStateFields(),
+ table: table
+ });
+
+ const deleteCustomState = useDeleteApiFormModal({
+ url: ApiEndpoints.custom_state_list,
+ pk: selectedCustomState,
+ title: t`Delete State`,
+ table: table
+ });
+
+ const rowActions = useCallback(
+ (record: any): RowAction[] => {
+ return [
+ RowEditAction({
+ hidden: !user.hasChangeRole(UserRoles.admin),
+ onClick: () => {
+ setSelectedCustomState(record.pk);
+ editCustomState.open();
+ }
+ }),
+ RowDeleteAction({
+ hidden: !user.hasDeleteRole(UserRoles.admin),
+ onClick: () => {
+ setSelectedCustomState(record.pk);
+ deleteCustomState.open();
+ }
+ })
+ ];
+ },
+ [user]
+ );
+
+ const tableActions = useMemo(() => {
+ return [
+ newCustomState.open()}
+ tooltip={t`Add state`}
+ />
+ ];
+ }, []);
+
+ return (
+ <>
+ {newCustomState.modal}
+ {editCustomState.modal}
+ {deleteCustomState.modal}
+
+ >
+ );
+}
|