diff --git a/docs/docs/concepts/custom_states.md b/docs/docs/concepts/custom_states.md new file mode 100644 index 000000000000..e72a949fafdb --- /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 0a5237ff668f..f132903cd3d2 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 19169c22982d..7639ddcb039d 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 cdbfba88b1cf..c02653fd175d 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 e44666fb02c7..a8834a2d0d78 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 3ae6f1282305..aadf175de9bf 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 ed0e673d02df..059a7fb10bae 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 5df3a8beb580..40bff36d6950 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 11bbb52e213e..78a94431306b 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 63a1c43f8f49..2ddae791cf86 100644 --- a/src/backend/InvenTree/InvenTree/helpers.py +++ b/src/backend/InvenTree/InvenTree/helpers.py @@ -953,8 +953,15 @@ def get_target(self, obj): 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 7805d73c0a77..15a5f2f079f5 100644 --- a/src/backend/InvenTree/InvenTree/metadata.py +++ b/src/backend/InvenTree/InvenTree/metadata.py @@ -363,7 +363,7 @@ def get_field_info(self, field): 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 @@ def get_field_info(self, field): 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 000000000000..9e0776fd09a4 --- /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 adde7080da2d..899145f98d6c 100644 --- a/src/backend/InvenTree/build/models.py +++ b/src/backend/InvenTree/build/models.py @@ -43,6 +43,7 @@ import report.mixins import stock.models import users.models +import generic.states logger = logging.getLogger('inventree') @@ -315,7 +316,7 @@ def get_absolute_url(self): 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 ade07fbdf9c2..473c4b423d33 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 Meta: '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 56c8a3a5d691..bc7fc47ddc9b 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 6c2760558f1c..e9c4b5b9d2f5 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 @@ {% 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 @@ {% 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 15e0612d7078..df93bd114078 100644 --- a/src/backend/InvenTree/build/templates/build/detail.html +++ b/src/backend/InvenTree/build/templates/build/detail.html @@ -60,7 +60,7 @@

{% trans "Build Details" %}

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