diff --git a/docs/docs/concepts/custom_states.md b/docs/docs/concepts/custom_states.md
new file mode 100644
index 0000000000..e72a949faf
--- /dev/null
+++ b/docs/docs/concepts/custom_states.md
@@ -0,0 +1,15 @@
+---
+title: Custom States
+---
+
+## Custom States
+
+Several models within InvenTree support the use of custom states. The custom states are display only - the business logic is not affected by the state.
+
+States can be added in the Admin Center under the "Custom States" section. Each state has a name, label and a color that are used to display the state in the user interface. Changes to these settings will only be reflected in the user interface after a full reload of the interface.
+
+States need to be assigned to a model, state (for example status on a StockItem) and a logical key - that will be used for business logic. These 3 values combined need to be unique throughout the system.
+
+Custom states can be used in the following models:
+- StockItem
+- Orders (PurchaseOrder, SalesOrder, ReturnOrder, ReturnOrderLine)
diff --git a/docs/docs/extend/machines/overview.md b/docs/docs/extend/machines/overview.md
index 0a5237ff66..f132903cd3 100644
--- a/docs/docs/extend/machines/overview.md
+++ b/docs/docs/extend/machines/overview.md
@@ -47,6 +47,8 @@ If you want to create your own machine type, please also take a look at the alre
```py
from django.utils.translation import gettext_lazy as _
+
+from generic.states import ColorEnum
from plugin.machine import BaseDriver, BaseMachineType, MachineStatus
class ABCBaseDriver(BaseDriver):
@@ -72,9 +74,9 @@ class ABCMachine(BaseMachineType):
base_driver = ABCBaseDriver
class ABCStatus(MachineStatus):
- CONNECTED = 100, _('Connected'), 'success'
- STANDBY = 101, _('Standby'), 'success'
- PRINTING = 110, _('Printing'), 'primary'
+ CONNECTED = 100, _('Connected'), ColorEnum.success
+ STANDBY = 101, _('Standby'), ColorEnum.success
+ PRINTING = 110, _('Printing'), ColorEnum.primary
MACHINE_STATUS = ABCStatus
default_machine_status = ABCStatus.DISCONNECTED
diff --git a/docs/docs/order/purchase_order.md b/docs/docs/order/purchase_order.md
index 19169c2298..7639ddcb03 100644
--- a/docs/docs/order/purchase_order.md
+++ b/docs/docs/order/purchase_order.md
@@ -38,6 +38,8 @@ Refer to the source code for the Purchase Order status codes:
show_source: True
members: []
+Purchase Order Status supports [custom states](../concepts/custom_states.md).
+
### Purchase Order Currency
The currency code can be specified for an individual purchase order. If not specified, the default currency specified against the [supplier](./company.md#suppliers) will be used.
diff --git a/docs/docs/order/return_order.md b/docs/docs/order/return_order.md
index cdbfba88b1..c02653fd17 100644
--- a/docs/docs/order/return_order.md
+++ b/docs/docs/order/return_order.md
@@ -61,6 +61,8 @@ Refer to the source code for the Return Order status codes:
show_source: True
members: []
+Return Order Status supports [custom states](../concepts/custom_states.md).
+
## Create a Return Order
From the Return Order index, click on New Return Order which opens the "Create Return Order" form.
diff --git a/docs/docs/order/sales_order.md b/docs/docs/order/sales_order.md
index e44666fb02..a8834a2d0d 100644
--- a/docs/docs/order/sales_order.md
+++ b/docs/docs/order/sales_order.md
@@ -39,6 +39,8 @@ Refer to the source code for the Sales Order status codes:
show_source: True
members: []
+Sales Order Status supports [custom states](../concepts/custom_states.md).
+
### Sales Order Currency
The currency code can be specified for an individual sales order. If not specified, the default currency specified against the [customer](./company.md#customers) will be used.
diff --git a/docs/docs/stock/status.md b/docs/docs/stock/status.md
index 3ae6f12823..aadf175de9 100644
--- a/docs/docs/stock/status.md
+++ b/docs/docs/stock/status.md
@@ -10,7 +10,7 @@ Certain stock item status codes will restrict the availability of the stock item
Below is the list of available stock status codes and their meaning:
-| Status | Description | Available |
+| Status | Description | Available |
| ----------- | ----------- | --- |
| OK | Stock item is healthy, nothing wrong to report | Yes |
| Attention needed | Stock item hasn't been checked or tested yet | Yes |
@@ -38,6 +38,8 @@ Refer to the source code for the Stock status codes:
show_source: True
members: []
+Stock Status supports [custom states](../concepts/custom_states.md).
+
### Default Status Code
The default status code for any newly created Stock Item is OK
diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml
index ed0e673d02..059a7fb10b 100644
--- a/docs/mkdocs.yml
+++ b/docs/mkdocs.yml
@@ -77,6 +77,7 @@ nav:
- Core Concepts:
- Terminology: concepts/terminology.md
- Physical Units: concepts/units.md
+ - Custom States: concepts/custom_states.md
- Development:
- Contributing: develop/contributing.md
- Devcontainer: develop/devcontainer.md
diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py
index 5df3a8beb5..40bff36d69 100644
--- a/src/backend/InvenTree/InvenTree/api_version.py
+++ b/src/backend/InvenTree/InvenTree/api_version.py
@@ -1,13 +1,17 @@
"""InvenTree API version information."""
# InvenTree API version
-INVENTREE_API_VERSION = 245
+INVENTREE_API_VERSION = 246
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
+v246 - 2024-08-21 : https://github.com/inventree/InvenTree/pull/7862
+ - Adds custom status fields to various serializers
+ - Adds endpoints to admin custom status fields
+
v245 - 2024-08-21 : https://github.com/inventree/InvenTree/pull/7520
- Documented pagination fields (no functional changes)
diff --git a/src/backend/InvenTree/InvenTree/context.py b/src/backend/InvenTree/InvenTree/context.py
index 11bbb52e21..78a9443130 100644
--- a/src/backend/InvenTree/InvenTree/context.py
+++ b/src/backend/InvenTree/InvenTree/context.py
@@ -3,9 +3,9 @@
"""Provides extra global data to all templates."""
import InvenTree.email
+import InvenTree.ready
import InvenTree.status
-from generic.states import StatusCode
-from InvenTree.helpers import inheritors
+from generic.states.custom import get_custom_classes
from users.models import RuleSet, check_user_role
@@ -53,7 +53,10 @@ def status_codes(request):
return {}
request._inventree_status_codes = True
- return {cls.__name__: cls.template_context() for cls in inheritors(StatusCode)}
+ get_custom = InvenTree.ready.isRebuildingData() is False
+ return {
+ cls.__name__: cls.template_context() for cls in get_custom_classes(get_custom)
+ }
def user_roles(request):
diff --git a/src/backend/InvenTree/InvenTree/helpers.py b/src/backend/InvenTree/InvenTree/helpers.py
index 63a1c43f8f..2ddae791cf 100644
--- a/src/backend/InvenTree/InvenTree/helpers.py
+++ b/src/backend/InvenTree/InvenTree/helpers.py
@@ -953,8 +953,15 @@ def get_objectreference(
Inheritors_T = TypeVar('Inheritors_T')
-def inheritors(cls: type[Inheritors_T]) -> set[type[Inheritors_T]]:
- """Return all classes that are subclasses from the supplied cls."""
+def inheritors(
+ cls: type[Inheritors_T], subclasses: bool = True
+) -> set[type[Inheritors_T]]:
+ """Return all classes that are subclasses from the supplied cls.
+
+ Args:
+ cls: The class to search for subclasses
+ subclasses: Include subclasses of subclasses (default = True)
+ """
subcls = set()
work = [cls]
@@ -963,7 +970,8 @@ def inheritors(cls: type[Inheritors_T]) -> set[type[Inheritors_T]]:
for child in parent.__subclasses__():
if child not in subcls:
subcls.add(child)
- work.append(child)
+ if subclasses:
+ work.append(child)
return subcls
diff --git a/src/backend/InvenTree/InvenTree/metadata.py b/src/backend/InvenTree/InvenTree/metadata.py
index 7805d73c0a..15a5f2f079 100644
--- a/src/backend/InvenTree/InvenTree/metadata.py
+++ b/src/backend/InvenTree/InvenTree/metadata.py
@@ -363,7 +363,7 @@ class InvenTreeMetadata(SimpleMetadata):
field_info['type'] = 'related field'
field_info['model'] = model._meta.model_name
- # Special case for 'user' model
+ # Special case for special models
if field_info['model'] == 'user':
field_info['api_url'] = '/api/user/'
elif field_info['model'] == 'contenttype':
@@ -381,6 +381,14 @@ class InvenTreeMetadata(SimpleMetadata):
if field_info['type'] == 'dependent field':
field_info['depends_on'] = field.depends_on
+ # Extend field info if the field has a get_field_info method
+ if (
+ not field_info.get('read_only')
+ and hasattr(field, 'get_field_info')
+ and callable(field.get_field_info)
+ ):
+ field_info = field.get_field_info(field, field_info)
+
return field_info
diff --git a/src/backend/InvenTree/build/migrations/0052_build_status_custom_key_alter_build_status.py b/src/backend/InvenTree/build/migrations/0052_build_status_custom_key_alter_build_status.py
new file mode 100644
index 0000000000..9e0776fd09
--- /dev/null
+++ b/src/backend/InvenTree/build/migrations/0052_build_status_custom_key_alter_build_status.py
@@ -0,0 +1,39 @@
+# Generated by Django 4.2.14 on 2024-08-07 22:40
+
+import django.core.validators
+from django.db import migrations
+
+import generic.states.fields
+import InvenTree.status_codes
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("build", "0051_delete_buildorderattachment"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="build",
+ 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="build",
+ name="status",
+ field=generic.states.fields.InvenTreeCustomStatusModelField(
+ choices=InvenTree.status_codes.BuildStatus.items(),
+ default=10,
+ help_text="Build status code",
+ validators=[django.core.validators.MinValueValidator(0)],
+ verbose_name="Build Status",
+ ),
+ ),
+ ]
diff --git a/src/backend/InvenTree/build/models.py b/src/backend/InvenTree/build/models.py
index adde7080da..899145f98d 100644
--- a/src/backend/InvenTree/build/models.py
+++ b/src/backend/InvenTree/build/models.py
@@ -43,6 +43,7 @@ import part.models
import report.mixins
import stock.models
import users.models
+import generic.states
logger = logging.getLogger('inventree')
@@ -315,7 +316,7 @@ class Build(
help_text=_('Number of stock items which have been completed')
)
- status = models.PositiveIntegerField(
+ status = generic.states.fields.InvenTreeCustomStatusModelField(
verbose_name=_('Build Status'),
default=BuildStatus.PENDING.value,
choices=BuildStatus.items(),
diff --git a/src/backend/InvenTree/build/serializers.py b/src/backend/InvenTree/build/serializers.py
index ade07fbdf9..473c4b423d 100644
--- a/src/backend/InvenTree/build/serializers.py
+++ b/src/backend/InvenTree/build/serializers.py
@@ -2,45 +2,53 @@
from decimal import Decimal
-from django.db import transaction
from django.core.exceptions import ValidationError as DjangoValidationError
-from django.utils.translation import gettext_lazy as _
-
-from django.db import models
-from django.db.models import ExpressionWrapper, F, FloatField
-from django.db.models import Case, Sum, When, Value
-from django.db.models import BooleanField, Q
+from django.db import models, transaction
+from django.db.models import (
+ BooleanField,
+ Case,
+ ExpressionWrapper,
+ F,
+ FloatField,
+ Q,
+ Sum,
+ Value,
+ When,
+)
from django.db.models.functions import Coalesce
+from django.utils.translation import gettext_lazy as _
from rest_framework import serializers
from rest_framework.serializers import ValidationError
-from InvenTree.serializers import InvenTreeModelSerializer, UserSerializer
-
-import InvenTree.helpers
-import InvenTree.tasks
-from InvenTree.serializers import InvenTreeDecimalField, NotesFieldMixin
-from stock.status_codes import StockStatus
-
-from stock.generators import generate_batch_code
-from stock.models import StockItem, StockLocation
-from stock.serializers import StockItemSerializerBrief, LocationBriefSerializer
-
import build.tasks
import common.models
-from common.serializers import ProjectCodeSerializer
-from common.settings import get_global_setting
-from importer.mixins import DataImportExportSerializerMixin
import company.serializers
+import InvenTree.helpers
+import InvenTree.tasks
import part.filters
import part.serializers as part_serializers
+from common.serializers import ProjectCodeSerializer
+from common.settings import get_global_setting
+from generic.states.fields import InvenTreeCustomStatusSerializerMixin
+from importer.mixins import DataImportExportSerializerMixin
+from InvenTree.serializers import (
+ InvenTreeDecimalField,
+ InvenTreeModelSerializer,
+ NotesFieldMixin,
+ UserSerializer,
+)
+from stock.generators import generate_batch_code
+from stock.models import StockItem, StockLocation
+from stock.serializers import LocationBriefSerializer, StockItemSerializerBrief
+from stock.status_codes import StockStatus
from users.serializers import OwnerSerializer
-from .models import Build, BuildLine, BuildItem
+from .models import Build, BuildItem, BuildLine
from .status_codes import BuildStatus
-class BuildSerializer(NotesFieldMixin, DataImportExportSerializerMixin, InvenTreeModelSerializer):
+class BuildSerializer(NotesFieldMixin, DataImportExportSerializerMixin, InvenTreeCustomStatusSerializerMixin, InvenTreeModelSerializer):
"""Serializes a Build object."""
class Meta:
@@ -69,6 +77,7 @@ class BuildSerializer(NotesFieldMixin, DataImportExportSerializerMixin, InvenTre
'quantity',
'status',
'status_text',
+ 'status_custom_key',
'target_date',
'take_from',
'notes',
diff --git a/src/backend/InvenTree/build/status_codes.py b/src/backend/InvenTree/build/status_codes.py
index 56c8a3a5d6..bc7fc47ddc 100644
--- a/src/backend/InvenTree/build/status_codes.py
+++ b/src/backend/InvenTree/build/status_codes.py
@@ -2,17 +2,17 @@
from django.utils.translation import gettext_lazy as _
-from generic.states import StatusCode
+from generic.states import ColorEnum, StatusCode
class BuildStatus(StatusCode):
"""Build status codes."""
- PENDING = 10, _('Pending'), 'secondary' # Build is pending / active
- PRODUCTION = 20, _('Production'), 'primary' # Build is in production
- ON_HOLD = 25, _('On Hold'), 'warning' # Build is on hold
- CANCELLED = 30, _('Cancelled'), 'danger' # Build was cancelled
- COMPLETE = 40, _('Complete'), 'success' # Build is complete
+ PENDING = 10, _('Pending'), ColorEnum.secondary # Build is pending / active
+ PRODUCTION = 20, _('Production'), ColorEnum.primary # Build is in production
+ ON_HOLD = 25, _('On Hold'), ColorEnum.warning # Build is on hold
+ CANCELLED = 30, _('Cancelled'), ColorEnum.danger # Build was cancelled
+ COMPLETE = 40, _('Complete'), ColorEnum.success # Build is complete
class BuildStatusGroups:
diff --git a/src/backend/InvenTree/build/templates/build/build_base.html b/src/backend/InvenTree/build/templates/build/build_base.html
index 6c2760558f..e9c4b5b9d2 100644
--- a/src/backend/InvenTree/build/templates/build/build_base.html
+++ b/src/backend/InvenTree/build/templates/build/build_base.html
@@ -158,7 +158,7 @@ src="{% static 'img/blank_image.png' %}"
|
{% trans "Status" %} |
- {% status_label 'build' build.status %}
+ {% display_status_label 'build' build.status_custom_key build.status %}
|
{% if build.target_date %}
@@ -225,7 +225,7 @@ src="{% static 'img/blank_image.png' %}"
{% block page_data %}
- {% status_label 'build' build.status large=True %}
+ {% display_status_label 'build' build.status_custom_key build.status large=True %}
{% if build.is_overdue %}
{% trans "Overdue" %}
{% endif %}
diff --git a/src/backend/InvenTree/build/templates/build/detail.html b/src/backend/InvenTree/build/templates/build/detail.html
index 15e0612d70..df93bd1140 100644
--- a/src/backend/InvenTree/build/templates/build/detail.html
+++ b/src/backend/InvenTree/build/templates/build/detail.html
@@ -60,7 +60,7 @@
|
{% trans "Status" %} |
- {% status_label 'build' build.status %} |
+ {% display_status_label 'build' build.status_custom_key build.status %} |
|
diff --git a/src/backend/InvenTree/common/api.py b/src/backend/InvenTree/common/api.py
index 8701049231..fe5e622762 100644
--- a/src/backend/InvenTree/common/api.py
+++ b/src/backend/InvenTree/common/api.py
@@ -29,7 +29,7 @@ import common.models
import common.serializers
from common.icons import get_icon_packs
from common.settings import get_global_setting
-from generic.states.api import AllStatusViews, StatusView
+from generic.states.api import urlpattern as generic_states_api_urls
from importer.mixins import DataExportViewMixin
from InvenTree.api import BulkDeleteMixin, MetadataView
from InvenTree.config import CONFIG_LOOKUPS
@@ -655,6 +655,8 @@ class ContentTypeList(ListAPI):
queryset = ContentType.objects.all()
serializer_class = common.serializers.ContentTypeSerializer
permission_classes = [permissions.IsAuthenticated]
+ filter_backends = SEARCH_ORDER_FILTER
+ search_fields = ['app_label', 'model']
class ContentTypeDetail(RetrieveAPI):
@@ -965,16 +967,7 @@ common_api_urls = [
]),
),
# Status
- path(
- 'generic/status/',
- include([
- path(
- f'/',
- include([path('', StatusView.as_view(), name='api-status')]),
- ),
- path('', AllStatusViews.as_view(), name='api-status-all'),
- ]),
- ),
+ path('generic/status/', include(generic_states_api_urls)),
# Contenttype
path(
'contenttype/',
diff --git a/src/backend/InvenTree/common/migrations/0029_inventreecustomuserstatemodel.py b/src/backend/InvenTree/common/migrations/0029_inventreecustomuserstatemodel.py
new file mode 100644
index 0000000000..7090b98279
--- /dev/null
+++ b/src/backend/InvenTree/common/migrations/0029_inventreecustomuserstatemodel.py
@@ -0,0 +1,97 @@
+# Generated by Django 4.2.14 on 2024-08-07 22:40
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+from common.models import state_color_mappings
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("contenttypes", "0002_remove_content_type_name"),
+ ("common", "0028_colortheme_user_obj"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="InvenTreeCustomUserStateModel",
+ fields=[
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ (
+ "key",
+ models.IntegerField(
+ help_text="Value that will be saved in the models database",
+ verbose_name="Key",
+ ),
+ ),
+ (
+ "name",
+ models.CharField(
+ help_text="Name of the state",
+ max_length=250,
+ verbose_name="Name",
+ ),
+ ),
+ (
+ "label",
+ models.CharField(
+ help_text="Label that will be displayed in the frontend",
+ max_length=250,
+ verbose_name="label",
+ ),
+ ),
+ (
+ "color",
+ models.CharField(
+ choices=state_color_mappings(),
+ default="secondary",
+ help_text="Color that will be displayed in the frontend",
+ max_length=10,
+ verbose_name="Color",
+ ),
+ ),
+ (
+ "logical_key",
+ models.IntegerField(
+ help_text="State logical key that is equal to this custom state in business logic",
+ verbose_name="Logical Key",
+ ),
+ ),
+ (
+ "reference_status",
+ models.CharField(
+ help_text="Status set that is extended with this custom state",
+ max_length=250,
+ verbose_name="Reference Status Set",
+ ),
+ ),
+ (
+ "model",
+ models.ForeignKey(
+ blank=True,
+ help_text="Model this state is associated with",
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ to="contenttypes.contenttype",
+ verbose_name="Model",
+ ),
+ ),
+ ],
+ options={
+ "verbose_name": "Custom State",
+ "verbose_name_plural": "Custom States",
+ "unique_together": {
+ ("model", "reference_status", "key", "logical_key")
+ },
+ },
+ ),
+ ]
diff --git a/src/backend/InvenTree/common/models.py b/src/backend/InvenTree/common/models.py
index 9b34a4c37b..ca3b45c9dc 100644
--- a/src/backend/InvenTree/common/models.py
+++ b/src/backend/InvenTree/common/models.py
@@ -53,6 +53,8 @@ import order.validators
import plugin.base.barcodes.helper
import report.helpers
import users.models
+from generic.states import ColorEnum
+from generic.states.custom import get_custom_classes, state_color_mappings
from InvenTree.sanitizer import sanitize_svg
from plugin import registry
@@ -3339,3 +3341,109 @@ class Attachment(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel
raise ValidationError(_('Invalid model type specified for attachment'))
return model_class.check_attachment_permission(permission, user)
+
+
+class InvenTreeCustomUserStateModel(models.Model):
+ """Custom model to extends any registered state with extra custom, user defined states."""
+
+ key = models.IntegerField(
+ verbose_name=_('Key'),
+ help_text=_('Value that will be saved in the models database'),
+ )
+ name = models.CharField(
+ max_length=250, verbose_name=_('Name'), help_text=_('Name of the state')
+ )
+ 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(),
+ default=ColorEnum.secondary.value,
+ 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,
+ null=True,
+ blank=True,
+ 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."""
+ return f'{self.model.name} ({self.reference_status}): {self.name} | {self.key} ({self.logical_key})'
+
+ def save(self, *args, **kwargs) -> None:
+ """Ensure that the custom state is valid before saving."""
+ self.clean()
+ return super().save(*args, **kwargs)
+
+ def clean(self) -> None:
+ """Validate custom state data."""
+ if self.model is None:
+ raise ValidationError({'model': _('Model must be selected')})
+
+ if self.key is None:
+ raise ValidationError({'key': _('Key must be selected')})
+
+ if self.logical_key is None:
+ raise ValidationError({'logical_key': _('Logical key must be selected')})
+
+ # Ensure that the key is not the same as the logical key
+ 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 == '':
+ raise ValidationError({
+ 'reference_status': _('Reference status must be selected')
+ })
+
+ # 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():
+ raise ValidationError({
+ 'key': _(
+ 'Key must be different from the logical keys of the reference status'
+ )
+ })
+ if self.logical_key not in ref_set.keys():
+ raise ValidationError({
+ 'logical_key': _(
+ 'Logical key must be in the logical keys of the reference status'
+ )
+ })
+
+ return super().clean()
diff --git a/src/backend/InvenTree/common/serializers.py b/src/backend/InvenTree/common/serializers.py
index 4c1f6a30dd..9f69bbffff 100644
--- a/src/backend/InvenTree/common/serializers.py
+++ b/src/backend/InvenTree/common/serializers.py
@@ -14,6 +14,7 @@ from taggit.serializers import TagListSerializerField
import common.models as common_models
import common.validators
+import generic.states.custom
from importer.mixins import DataImportExportSerializerMixin
from importer.registry import register_importer
from InvenTree.helpers import get_objectreference
@@ -308,6 +309,32 @@ class ProjectCodeSerializer(DataImportExportSerializerMixin, InvenTreeModelSeria
responsible_detail = OwnerSerializer(source='responsible', read_only=True)
+@register_importer()
+class CustomStateSerializer(DataImportExportSerializerMixin, InvenTreeModelSerializer):
+ """Serializer for the custom state model."""
+
+ class Meta:
+ """Meta options for CustomStateSerializer."""
+
+ model = common_models.InvenTreeCustomUserStateModel
+ fields = [
+ 'pk',
+ 'key',
+ 'name',
+ 'label',
+ 'color',
+ 'logical_key',
+ 'model',
+ 'model_name',
+ 'reference_status',
+ ]
+
+ model_name = serializers.CharField(read_only=True, source='model.name')
+ reference_status = serializers.ChoiceField(
+ choices=generic.states.custom.state_reference_mappings()
+ )
+
+
class FlagSerializer(serializers.Serializer):
"""Serializer for feature flags."""
diff --git a/src/backend/InvenTree/common/tests.py b/src/backend/InvenTree/common/tests.py
index c76d17f5b9..2d6279734d 100644
--- a/src/backend/InvenTree/common/tests.py
+++ b/src/backend/InvenTree/common/tests.py
@@ -33,6 +33,7 @@ from .models import (
Attachment,
ColorTheme,
CustomUnit,
+ InvenTreeCustomUserStateModel,
InvenTreeSetting,
InvenTreeUserSetting,
NotesImage,
@@ -1586,3 +1587,93 @@ class ValidatorsTest(TestCase):
common.validators.validate_icon('ti:package:non-existing-variant')
common.validators.validate_icon('ti:package:outline')
+
+
+class CustomStatusTest(TestCase):
+ """Unit tests for the custom status model."""
+
+ def setUp(self):
+ """Setup for all tests."""
+ self.data = {
+ 'key': 11,
+ 'name': 'OK - advanced',
+ 'label': 'OK - adv.',
+ 'color': 'secondary',
+ 'logical_key': 10,
+ 'model': ContentType.objects.get(model='stockitem'),
+ 'reference_status': 'StockStatus',
+ }
+
+ def test_validation_model(self):
+ """Test that model is present."""
+ data = self.data
+ data.pop('model')
+ with self.assertRaises(ValidationError):
+ InvenTreeCustomUserStateModel.objects.create(**data)
+ self.assertEqual(InvenTreeCustomUserStateModel.objects.count(), 0)
+
+ def test_validation_key(self):
+ """Tests Model must have a key."""
+ data = self.data
+ data.pop('key')
+ with self.assertRaises(ValidationError):
+ InvenTreeCustomUserStateModel.objects.create(**data)
+ self.assertEqual(InvenTreeCustomUserStateModel.objects.count(), 0)
+
+ def test_validation_logicalkey(self):
+ """Tests Logical key must be present."""
+ data = self.data
+ data.pop('logical_key')
+ with self.assertRaises(ValidationError):
+ InvenTreeCustomUserStateModel.objects.create(**data)
+ self.assertEqual(InvenTreeCustomUserStateModel.objects.count(), 0)
+
+ def test_validation_reference(self):
+ """Tests Reference status must be present."""
+ data = self.data
+ data.pop('reference_status')
+ with self.assertRaises(ValidationError):
+ InvenTreeCustomUserStateModel.objects.create(**data)
+ self.assertEqual(InvenTreeCustomUserStateModel.objects.count(), 0)
+
+ def test_validation_logical_unique(self):
+ """Tests Logical key must be unique."""
+ data = self.data
+ data['logical_key'] = data['key']
+ with self.assertRaises(ValidationError):
+ InvenTreeCustomUserStateModel.objects.create(**data)
+ self.assertEqual(InvenTreeCustomUserStateModel.objects.count(), 0)
+
+ def test_validation_reference_exsists(self):
+ """Tests Reference status set not found."""
+ data = self.data
+ data['reference_status'] = 'abcd'
+ with self.assertRaises(ValidationError):
+ InvenTreeCustomUserStateModel.objects.create(**data)
+ self.assertEqual(InvenTreeCustomUserStateModel.objects.count(), 0)
+
+ def test_validation_key_unique(self):
+ """Tests Key must be different from the logical keys of the reference."""
+ data = self.data
+ data['key'] = 50
+ with self.assertRaises(ValidationError):
+ InvenTreeCustomUserStateModel.objects.create(**data)
+ self.assertEqual(InvenTreeCustomUserStateModel.objects.count(), 0)
+
+ def test_validation_logical_key_exsists(self):
+ """Tests Logical key must be in the logical keys of the reference status."""
+ data = self.data
+ data['logical_key'] = 12
+ with self.assertRaises(ValidationError):
+ InvenTreeCustomUserStateModel.objects.create(**data)
+ self.assertEqual(InvenTreeCustomUserStateModel.objects.count(), 0)
+
+ def test_validation(self):
+ """Tests Valid run."""
+ data = self.data
+ instance = InvenTreeCustomUserStateModel.objects.create(**data)
+ self.assertEqual(data['key'], instance.key)
+ self.assertEqual(InvenTreeCustomUserStateModel.objects.count(), 1)
+ self.assertEqual(
+ instance.__str__(), 'Stock Item (StockStatus): OK - advanced | 11 (10)'
+ )
diff --git a/src/backend/InvenTree/generic/states/__init__.py b/src/backend/InvenTree/generic/states/__init__.py
index 8d5fdfed71..a13139b5ff 100644
--- a/src/backend/InvenTree/generic/states/__init__.py
+++ b/src/backend/InvenTree/generic/states/__init__.py
@@ -6,7 +6,13 @@ 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 StatusCode
+from .states import ColorEnum, StatusCode
from .transition import StateTransitionMixin, TransitionMethod, storage
-__all__ = ['StatusCode', 'storage', 'TransitionMethod', 'StateTransitionMixin']
+__all__ = [
+ 'ColorEnum',
+ 'StatusCode',
+ 'storage',
+ 'TransitionMethod',
+ 'StateTransitionMixin',
+]
diff --git a/src/backend/InvenTree/generic/states/api.py b/src/backend/InvenTree/generic/states/api.py
index 203a6dea3a..49c7425335 100644
--- a/src/backend/InvenTree/generic/states/api.py
+++ b/src/backend/InvenTree/generic/states/api.py
@@ -2,12 +2,22 @@
import inspect
+from django.urls import include, path
+
from drf_spectacular.utils import OpenApiResponse, extend_schema
from rest_framework import permissions, serializers
from rest_framework.generics import GenericAPIView
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 .states import StatusCode
@@ -73,18 +83,52 @@ class AllStatusViews(StatusView):
def get(self, request, *args, **kwargs):
"""Perform a GET request to learn information about status codes."""
- data = {}
-
- def discover_status_codes(parent_status_class, prefix=None):
- """Recursively discover status classes."""
- for status_class in parent_status_class.__subclasses__():
- name = '__'.join([*(prefix or []), status_class.__name__])
- data[name] = {
- 'class': status_class.__name__,
- 'values': status_class.dict(),
- }
- discover_status_codes(status_class, [name])
-
- discover_status_codes(StatusCode)
-
+ data = get_status_api_response()
+ # Extend with MachineStatus classes
+ data.update(get_status_api_response(MachineStatus, prefix=['MachineStatus']))
return Response(data)
+
+
+# Custom states
+class CustomStateList(DataExportViewMixin, ListCreateAPI):
+ """List view for all custom states."""
+
+ queryset = common.models.InvenTreeCustomUserStateModel.objects.all()
+ serializer_class = common.serializers.CustomStateSerializer
+ permission_classes = [permissions.IsAuthenticated, IsStaffOrReadOnly]
+ filter_backends = SEARCH_ORDER_FILTER
+ ordering_fields = ['key']
+ search_fields = ['key', 'name', 'label', 'reference_status']
+
+
+class CustomStateDetail(RetrieveUpdateDestroyAPI):
+ """Detail view for a particular custom states."""
+
+ queryset = common.models.InvenTreeCustomUserStateModel.objects.all()
+ serializer_class = common.serializers.CustomStateSerializer
+ permission_classes = [permissions.IsAuthenticated, IsStaffOrReadOnly]
+
+
+urlpattern = [
+ # Custom state
+ path(
+ 'custom/',
+ include([
+ path(
+ '/', CustomStateDetail.as_view(), name='api-custom-state-detail'
+ ),
+ path('', CustomStateList.as_view(), name='api-custom-state-list'),
+ ]),
+ ),
+ # Generic status views
+ path(
+ '',
+ include([
+ path(
+ f'/',
+ 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 0000000000..2539eb550e
--- /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 0000000000..99717d4884
--- /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 c72b201eca..1ac7de1174 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 @@ class StatusCode(BaseEnum):
# 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 @@ class StatusCode(BaseEnum):
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 19e1471b28..f93a6b8a96 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 c30da83ae3..dbba2d14b2 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 @@ from .states import StatusCode
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 @@ class GeneralStateTest(InvenTreeTestCase):
# 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 @@ class GeneralStateTest(InvenTreeTestCase):
# 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 @@ class GeneralStateTest(InvenTreeTestCase):
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 71d4dfd0e6..2a884cec17 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 e2817322c5..119da641af 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 PIL.Image import Image
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 0000000000..26993943b5
--- /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 1a8124cf5c..88b3325da6 100644
--- a/src/backend/InvenTree/order/models.py
+++ b/src/backend/InvenTree/order/models.py
@@ -37,6 +37,7 @@ 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.fields import InvenTreeCustomStatusModelField
from InvenTree.exceptions import log_error
from InvenTree.fields import (
InvenTreeModelMoneyField,
@@ -470,7 +471,7 @@ class PurchaseOrder(TotalPriceMixin, Order):
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 @@ class SalesOrder(TotalPriceMixin, Order):
"""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 @@ class ReturnOrder(TotalPriceMixin, Order):
"""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 @@ class ReturnOrderLineItem(OrderLineItem):
"""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 ebfd0bd8f5..d0d07c14d8 100644
--- a/src/backend/InvenTree/order/serializers.py
+++ b/src/backend/InvenTree/order/serializers.py
@@ -32,6 +32,7 @@ from company.serializers import (
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 @@ class AbstractOrderSerializer(DataImportExportSerializerMixin, serializers.Seria
'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 @@ class PurchaseOrderReceiveSerializer(serializers.Serializer):
@register_importer()
class SalesOrderSerializer(
- NotesFieldMixin, TotalPriceMixin, AbstractOrderSerializer, InvenTreeModelSerializer
+ NotesFieldMixin,
+ TotalPriceMixin,
+ InvenTreeCustomStatusSerializerMixin,
+ AbstractOrderSerializer,
+ InvenTreeModelSerializer,
):
"""Serializer for the SalesOrder model class."""
@@ -1642,7 +1652,11 @@ class SalesOrderExtraLineSerializer(
@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 bc5df2bca3..3dcbee01f5 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 b02aafa19b..77e044120b 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 @@ src="{% static 'img/blank_image.png' %}"
|
{% 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 494701abf1..15755c4199 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 @@ src="{% static 'img/blank_image.png' %}"
| |
{% 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 c8d0179aa1..987b2e49d2 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 @@ src="{% static 'img/blank_image.png' %}"
| |
{% 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 5845163e63..c95fb4036b 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 @@ class LabelMixinTests(PrintTestMixins, InvenTreeAPITestCase):
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 8f05fa0041..e534a3ab39 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 ced26f60fa..cd1bffab79 100644
--- a/src/backend/InvenTree/stock/admin.py
+++ b/src/backend/InvenTree/stock/admin.py
@@ -142,6 +142,7 @@ class StockItemResource(InvenTreeResource):
'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 0000000000..5b9bc7e8d6
--- /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 3008f77970..e0482795d5 100644
--- a/src/backend/InvenTree/stock/models.py
+++ b/src/backend/InvenTree/stock/models.py
@@ -37,13 +37,18 @@ from build import models as BuildModels
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 @@ class StockItem(
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 8f83b59751..58ed3a2471 100644
--- a/src/backend/InvenTree/stock/serializers.py
+++ b/src/backend/InvenTree/stock/serializers.py
@@ -27,6 +27,7 @@ import part.serializers as part_serializers
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 @@ class StockItemSerializerBrief(
@register_importer()
class StockItemSerializer(
- DataImportExportSerializerMixin, InvenTree.serializers.InvenTreeTagModelSerializer
+ DataImportExportSerializerMixin,
+ InvenTreeCustomStatusSerializerMixin,
+ InvenTree.serializers.InvenTreeTagModelSerializer,
):
"""Serializer for a StockItem.
@@ -373,6 +376,7 @@ class StockItemSerializer(
'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 3c646bc455..d59d622569 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 fac0adf14d..8550bc9419 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 @@
|
|
{% 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 61a8a43ba7..af73fa1b73 100644
--- a/src/backend/InvenTree/stock/test_api.py
+++ b/src/backend/InvenTree/stock/test_api.py
@@ -7,6 +7,7 @@ from datetime import datetime, timedelta
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 @@ from rest_framework import status
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 @@ class StockItemListTest(StockAPITestCase):
)
+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 ba5b7d58a4..58792bd479 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 1407f7644a..9080b6b27d 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 33f3f24f2b..d99223256c 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 c22330c87d..57ac610185 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 358c91b2ae..542330156d 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 f9c5c2b3e3..6c21536343 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 1fcfc9d901..cb4af4c592 100644
--- a/src/backend/InvenTree/users/models.py
+++ b/src/backend/InvenTree/users/models.py
@@ -345,6 +345,7 @@ class RuleSet(models.Model):
'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 90ddbf6cc3..5a8687ba7e 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 e201ee1fc0..3d04f990df 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 5938eb34ef..c2df42a7d3 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 ef30cfa0c2..20b5fa1bb7 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 416f45f1fd..72f5544f4f 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 a7221d62b7..740b73336e 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 f20e3f1ec6..15d36d2d36 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 1590c4a55b..1b6a730191 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 10ce0a082e..40c060b92d 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 8688680579..0577233192 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 91bc1d8982..8f84261329 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 ff0f30a8c2..1348ff1e00 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 a2eacb9d7b..f1a6076ccd 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 f623f35857..2cfb1a47a5 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 73c9173545..e2306df852 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}
+
+ >
+ );
+}