From a66e3d5e0572e478d13bc09c80cac6f7dd681df3 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Fri, 9 Aug 2024 00:04:49 +0200 Subject: [PATCH 01/59] Add custom user defined states --- src/backend/InvenTree/InvenTree/context.py | 5 +- .../InvenTree/InvenTree/generic_fields.py | 125 ++++++++++++++++++ src/backend/InvenTree/InvenTree/metadata.py | 8 ++ src/backend/InvenTree/InvenTree/models.py | 2 + .../InvenTree/InvenTree/serializers.py | 105 ++++++++++++++- ...ld_status_custom_key_alter_build_status.py | 37 ++++++ src/backend/InvenTree/build/models.py | 3 +- src/backend/InvenTree/build/status_codes.py | 11 +- .../build/templates/build/build_base.html | 4 +- .../build/templates/build/detail.html | 2 +- src/backend/InvenTree/common/admin.py | 1 + .../0029_inventreecustomuserstatemodel.py | 104 +++++++++++++++ src/backend/InvenTree/common/models.py | 106 +++++++++++++++ .../InvenTree/generic/states/custom.py | 50 +++++++ .../InvenTree/generic/states/states.py | 30 ++++- src/backend/InvenTree/generic/states/tags.py | 21 ++- src/backend/InvenTree/generic/states/tests.py | 5 +- ...urchaseorder_status_custom_key_and_more.py | 99 ++++++++++++++ src/backend/InvenTree/order/models.py | 9 +- src/backend/InvenTree/order/status_codes.py | 53 ++++---- .../order/templates/order/order_base.html | 2 +- .../templates/order/return_order_base.html | 2 +- .../templates/order/sales_order_base.html | 2 +- src/backend/InvenTree/stock/admin.py | 1 + ...13_stockitem_status_custom_key_and_more.py | 54 ++++++++ src/backend/InvenTree/stock/models.py | 11 +- src/backend/InvenTree/stock/serializers.py | 5 +- src/backend/InvenTree/stock/status_codes.py | 21 +-- .../stock/templates/stock/item_base.html | 2 +- .../templates/js/translated/build.js | 10 +- .../InvenTree/templates/js/translated/part.js | 2 +- .../templates/js/translated/purchase_order.js | 4 +- .../templates/js/translated/return_order.js | 4 +- .../templates/js/translated/sales_order.js | 4 +- .../templates/js/translated/stock.js | 21 +-- src/backend/InvenTree/users/models.py | 1 + 36 files changed, 838 insertions(+), 88 deletions(-) create mode 100644 src/backend/InvenTree/InvenTree/generic_fields.py create mode 100644 src/backend/InvenTree/build/migrations/0052_build_status_custom_key_alter_build_status.py create mode 100644 src/backend/InvenTree/common/migrations/0029_inventreecustomuserstatemodel.py create mode 100644 src/backend/InvenTree/generic/states/custom.py create mode 100644 src/backend/InvenTree/order/migrations/0101_purchaseorder_status_custom_key_and_more.py create mode 100644 src/backend/InvenTree/stock/migrations/0113_stockitem_status_custom_key_and_more.py diff --git a/src/backend/InvenTree/InvenTree/context.py b/src/backend/InvenTree/InvenTree/context.py index 11bbb52e213e..1988536f2a0a 100644 --- a/src/backend/InvenTree/InvenTree/context.py +++ b/src/backend/InvenTree/InvenTree/context.py @@ -4,8 +4,7 @@ import InvenTree.email 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 +52,7 @@ def status_codes(request): return {} request._inventree_status_codes = True - return {cls.__name__: cls.template_context() for cls in inheritors(StatusCode)} + return {cls.__name__: cls.template_context() for cls in get_custom_classes()} def user_roles(request): diff --git a/src/backend/InvenTree/InvenTree/generic_fields.py b/src/backend/InvenTree/InvenTree/generic_fields.py new file mode 100644 index 000000000000..0fb97a31f2e5 --- /dev/null +++ b/src/backend/InvenTree/InvenTree/generic_fields.py @@ -0,0 +1,125 @@ +"""Custom model/serializer fields for InvenTree models that support custom states.""" + +from typing import Any, Iterable + +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 + + +class CustomChoiceField(serializers.ChoiceField): + """Custom Choice Field.""" + + 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) + super().__init__(choices, **kwargs) + self.choice_mdl = choice_mdl + self.choice_field = choice_field + self.is_is_custom = is_custom + + def get_logical_value(self, value): + """Get the state model for the selected value.""" + from common.models import InvenTreeCustomUserStateModel + + return InvenTreeCustomUserStateModel.objects.get( + key=value, model__model=self.choice_mdl._meta.model_name + ) + + 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: + from common.models import InvenTreeCustomUserStateModel + + try: + logical = self.get_logical_value(data) + if self.is_is_custom: + return logical.key + return logical.logical_key + except InvenTreeCustomUserStateModel.DoesNotExist: + 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 InvenTreeCustomStatusExtraModelField(models.PositiveIntegerField): + """Cusotm field used to detect custom extenteded fields.""" + + +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. + """ + + 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 = InvenTreeCustomStatusExtraModelField( + 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 ExtraCustomChoiceField(CustomChoiceField): + """Custom Choice Field that returns value of status if empty.""" + + def to_representation(self, value): + """Return the value of the status if it is empty.""" + return super().to_representation(value) or value diff --git a/src/backend/InvenTree/InvenTree/metadata.py b/src/backend/InvenTree/InvenTree/metadata.py index 7805d73c0a77..74a66f50599d 100644 --- a/src/backend/InvenTree/InvenTree/metadata.py +++ b/src/backend/InvenTree/InvenTree/metadata.py @@ -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/InvenTree/models.py b/src/backend/InvenTree/InvenTree/models.py index c9f7a31253bc..c08c63682e24 100644 --- a/src/backend/InvenTree/InvenTree/models.py +++ b/src/backend/InvenTree/InvenTree/models.py @@ -21,6 +21,8 @@ import InvenTree.format import InvenTree.helpers import InvenTree.helpers_model +from generic.states.states import ColorEnum, StatusCode +from InvenTree.helpers import inheritors logger = logging.getLogger('inventree') diff --git a/src/backend/InvenTree/InvenTree/serializers.py b/src/backend/InvenTree/InvenTree/serializers.py index 406368511559..aebe193bb824 100644 --- a/src/backend/InvenTree/InvenTree/serializers.py +++ b/src/backend/InvenTree/InvenTree/serializers.py @@ -4,6 +4,7 @@ from collections import OrderedDict from copy import deepcopy from decimal import Decimal +from typing import Optional from django.conf import settings from django.contrib.auth.models import User @@ -17,7 +18,7 @@ from djmoney.utils import MONEY_CLASSES, get_currency_field_name from rest_framework import serializers from rest_framework.exceptions import PermissionDenied, ValidationError -from rest_framework.fields import empty +from rest_framework.fields import ChoiceField, empty from rest_framework.mixins import ListModelMixin from rest_framework.serializers import DecimalField from rest_framework.utils import model_meta @@ -26,6 +27,12 @@ import common.models as common_models from common.currency import currency_code_default, currency_code_mappings from InvenTree.fields import InvenTreeRestURLField, InvenTreeURLField +from InvenTree.generic_fields import ( + CustomChoiceField, + ExtraCustomChoiceField, + InvenTreeCustomStatusExtraModelField, + InvenTreeCustomStatusModelField, +) class EmptySerializer(serializers.Serializer): @@ -203,6 +210,102 @@ def to_representation(self, value): return None +class InvenTreeCustomStatusSerializerMixin: + """Mixin to ensure custom status fields are set.""" + + _custom_fields: Optional[list] = None + _custom_fields_leader: Optional[list] = None + _custom_fields_follower: Optional[list] = None + _is_gathering = False + + def create(self, validated_data): + """Ensure the custom field is set when the leader is set initially.""" + self.gather_custom_fields() + for field in self._custom_fields_leader: + if field in self.initial_data: + validated_data[f'{field}_custom_key'] = int(self.initial_data[field]) + return super().create(validated_data) + + 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]) + 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(): + try: + if data[field] is None: + data[field] = data[ + field.replace('_custom_key', '') + ] # Use "normal" status field instead + except KeyError: + pass + 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_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, InvenTreeCustomStatusExtraModelField): + 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: + field_kwargs['choices'] = self.fields[ + field_name.replace('_custom_key', '') + ].choices + else: + field_kwargs['choices'] = [] + return field_cls, field_kwargs + + class InvenTreeModelSerializer(serializers.ModelSerializer): """Inherits the standard Django ModelSerializer class, but also ensures that the underlying model class data are checked on validation.""" 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..504ae36159de --- /dev/null +++ b/src/backend/InvenTree/build/migrations/0052_build_status_custom_key_alter_build_status.py @@ -0,0 +1,37 @@ +# Generated by Django 4.2.14 on 2024-08-07 22:40 + +import InvenTree.generic_fields +import django.core.validators +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("build", "0051_delete_buildorderattachment"), + ] + + operations = [ + migrations.AddField( + model_name="build", + name="status_custom_key", + field=InvenTree.generic_fields.InvenTreeCustomStatusExtraModelField( + 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=InvenTree.generic_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 eb0449ea941a..930b1d0c7187 100644 --- a/src/backend/InvenTree/build/models.py +++ b/src/backend/InvenTree/build/models.py @@ -21,6 +21,7 @@ from rest_framework import serializers +import InvenTree.generic_fields from build.status_codes import BuildStatus, BuildStatusGroups from stock.status_codes import StockStatus, StockHistoryCode @@ -315,7 +316,7 @@ def get_absolute_url(self): help_text=_('Number of stock items which have been completed') ) - status = models.PositiveIntegerField( + status = InvenTree.generic_fields.InvenTreeCustomStatusModelField( verbose_name=_('Build Status'), default=BuildStatus.PENDING.value, choices=BuildStatus.items(), diff --git a/src/backend/InvenTree/build/status_codes.py b/src/backend/InvenTree/build/status_codes.py index 56c8a3a5d691..7ccb81b98912 100644 --- a/src/backend/InvenTree/build/status_codes.py +++ b/src/backend/InvenTree/build/status_codes.py @@ -3,16 +3,17 @@ from django.utils.translation import gettext_lazy as _ from generic.states import StatusCode +from generic.states.states import ColorEnum 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 536b95c6ecc7..bfc267650213 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 e1b2c47c61a7..86eeea0ac4a3 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/admin.py b/src/backend/InvenTree/common/admin.py index a0719f9ab463..c23c34b3c0a3 100644 --- a/src/backend/InvenTree/common/admin.py +++ b/src/backend/InvenTree/common/admin.py @@ -111,3 +111,4 @@ class NewsFeedEntryAdmin(admin.ModelAdmin): admin.site.register(common.models.WebhookMessage, ImportExportModelAdmin) +admin.site.register(common.models.InvenTreeCustomUserStateModel, admin.ModelAdmin) 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..ce899acb36a9 --- /dev/null +++ b/src/backend/InvenTree/common/migrations/0029_inventreecustomuserstatemodel.py @@ -0,0 +1,104 @@ +# Generated by Django 4.2.14 on 2024-08-07 22:40 + +from django.db import migrations, models +import django.db.models.deletion +import generic.states.states + + +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=[ + ("primary", "primary"), + ("secondary", "secondary"), + ("success", "success"), + ("danger", "danger"), + ("warning", "warning"), + ("info", "info"), + ("dark", "dark"), + ], + default=generic.states.states.ColorEnum["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 46e38b03d816..c49330d95c90 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.states import ColorEnum, StatusCode +from InvenTree.helpers import inheritors from InvenTree.sanitizer import sanitize_svg from plugin import registry @@ -3331,3 +3333,107 @@ def check_permission(self, permission, user): raise ValidationError(_('Invalid model type specified for attachment')) return model_class.check_attachment_permission(permission, user) + + +def state_color_mappings(): + """Return a list of custom user state colors.""" + return [(a.name, a.value) for a in ColorEnum] + + +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, + 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} ({self.reference_status}): {self.name} | {self.key} ({self.logical_key})' + + 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: + 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 = [ + x for x in inheritors(StatusCode) if x.__name__ == self.reference_status + ] + 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/generic/states/custom.py b/src/backend/InvenTree/generic/states/custom.py new file mode 100644 index 000000000000..ca105c6d3e76 --- /dev/null +++ b/src/backend/InvenTree/generic/states/custom.py @@ -0,0 +1,50 @@ +"""Helper functions for custom status labels.""" + +from common.models import InvenTreeCustomUserStateModel +from InvenTree.helpers import inheritors + +from .states import StatusCode + + +def get_custom_status_labels(): + """Return a dict of custom status labels.""" + return {cls.tag(): cls for cls in get_custom_classes()} + + +def get_custom_classes(include_custom: bool = True): + """Return a dict of status classes (custom and class defined).""" + if not include_custom: + return inheritors(StatusCode) + + # Gather DB settings + 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 inheritors(StatusCode): + 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] = StatusCode(f'{tag.capitalize()}Status', data) + return states.values() 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..cc235cd970b3 100644 --- a/src/backend/InvenTree/generic/states/tags.py +++ b/src/backend/InvenTree/generic/states/tags.py @@ -3,15 +3,28 @@ 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_classes, get_custom_status_labels @register.simple_tag -def status_label(typ: str, key: int, *args, **kwargs): +def status_label(typ: str, key: int, keys=None, *args, **kwargs): """Render a status label.""" - state = {cls.tag(): cls for cls in inheritors(StatusCode)}.get(typ, None) + if keys is None: + keys = {cls.tag(): cls for cls in get_custom_classes(include_custom=False)} + state = keys.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.""" + states = get_custom_status_labels() + if key: + render_key = int(key) + else: + render_key = fallback + return status_label(typ, render_key, *args, keys=states, **kwargs) diff --git a/src/backend/InvenTree/generic/states/tests.py b/src/backend/InvenTree/generic/states/tests.py index c30da83ae363..1a9582cfb810 100644 --- a/src/backend/InvenTree/generic/states/tests.py +++ b/src/backend/InvenTree/generic/states/tests.py @@ -5,6 +5,7 @@ from rest_framework.test import force_authenticate +from generic.states.states import ColorEnum from InvenTree.unit_test import InvenTreeTestCase from .api import StatusView @@ -14,9 +15,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 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..1368e3a52b6b --- /dev/null +++ b/src/backend/InvenTree/order/migrations/0101_purchaseorder_status_custom_key_and_more.py @@ -0,0 +1,99 @@ +# Generated by Django 4.2.14 on 2024-08-07 22:40 + +import InvenTree.generic_fields +from django.db import migrations +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=InvenTree.generic_fields.InvenTreeCustomStatusExtraModelField( + 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=InvenTree.generic_fields.InvenTreeCustomStatusExtraModelField( + 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=InvenTree.generic_fields.InvenTreeCustomStatusExtraModelField( + 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=InvenTree.generic_fields.InvenTreeCustomStatusExtraModelField( + 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=InvenTree.generic_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=InvenTree.generic_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=InvenTree.generic_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=InvenTree.generic_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 3d2039a84cd4..7cefec59c28c 100644 --- a/src/backend/InvenTree/order/models.py +++ b/src/backend/InvenTree/order/models.py @@ -44,6 +44,7 @@ InvenTreeURLField, RoundingDecimalField, ) +from InvenTree.generic_fields import InvenTreeCustomStatusModelField from InvenTree.helpers import decimal2string, pui_url from InvenTree.helpers_model import notify_responsible from order.status_codes import ( @@ -471,7 +472,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'), @@ -997,7 +998,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/status_codes.py b/src/backend/InvenTree/order/status_codes.py index bc5df2bca337..2f3a3e92d922 100644 --- a/src/backend/InvenTree/order/status_codes.py +++ b/src/backend/InvenTree/order/status_codes.py @@ -3,19 +3,20 @@ from django.utils.translation import gettext_lazy as _ from generic.states import StatusCode +from generic.states.states import ColorEnum 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 +40,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 +72,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 +96,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/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..222115ed8b83 --- /dev/null +++ b/src/backend/InvenTree/stock/migrations/0113_stockitem_status_custom_key_and_more.py @@ -0,0 +1,54 @@ +# Generated by Django 4.2.14 on 2024-08-07 22:40 + +import InvenTree.generic_fields +import django.core.validators +from django.db import migrations +import stock.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=InvenTree.generic_fields.InvenTreeCustomStatusExtraModelField( + blank=True, + default=None, + help_text="Additional status information for this item", + null=True, + verbose_name="Custom status key", + ), + ), + migrations.AddField( + model_name="stockitemtracking", + name="tracking_type_custom_key", + field=InvenTree.generic_fields.InvenTreeCustomStatusExtraModelField( + 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=InvenTree.generic_fields.InvenTreeCustomStatusModelField( + choices=InvenTree.status_codes.StockStatus.items(), + default=10, + validators=[django.core.validators.MinValueValidator(0)], + ), + ), + migrations.AlterField( + model_name="stockitemtracking", + name="tracking_type", + field=InvenTree.generic_fields.InvenTreeCustomStatusModelField( + default=stock.status_codes.StockHistoryCode["LEGACY"] + ), + ), + ] diff --git a/src/backend/InvenTree/stock/models.py b/src/backend/InvenTree/stock/models.py index 35ce61a99a71..4e3ebb577901 100644 --- a/src/backend/InvenTree/stock/models.py +++ b/src/backend/InvenTree/stock/models.py @@ -38,6 +38,13 @@ from common.settings import get_global_setting from company import models as CompanyModels from InvenTree.fields import InvenTreeModelMoneyField, InvenTreeURLField +from InvenTree.generic_fields import InvenTreeCustomStatusModelField +from InvenTree.status_codes import ( + SalesOrderStatusGroups, + StockHistoryCode, + StockStatus, + StockStatusGroups, +) from order.status_codes import SalesOrderStatusGroups from part import models as PartModels from plugin.events import trigger_event @@ -940,7 +947,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)], @@ -2358,7 +2365,7 @@ def label(self): return getattr(self, 'title', '') - tracking_type = models.IntegerField(default=StockHistoryCode.LEGACY) + tracking_type = InvenTreeCustomStatusModelField(default=StockHistoryCode.LEGACY) item = models.ForeignKey( StockItem, on_delete=models.CASCADE, related_name='tracking_info' diff --git a/src/backend/InvenTree/stock/serializers.py b/src/backend/InvenTree/stock/serializers.py index 8f83b59751e1..2625db838e8f 100644 --- a/src/backend/InvenTree/stock/serializers.py +++ b/src/backend/InvenTree/stock/serializers.py @@ -326,7 +326,9 @@ def validate_serial(self, value): @register_importer() class StockItemSerializer( - DataImportExportSerializerMixin, InvenTree.serializers.InvenTreeTagModelSerializer + DataImportExportSerializerMixin, + InvenTree.serializers.InvenTreeCustomStatusSerializerMixin, + InvenTree.serializers.InvenTreeTagModelSerializer, ): """Serializer for a StockItem. @@ -373,6 +375,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..23effb8f9b8c 100644 --- a/src/backend/InvenTree/stock/status_codes.py +++ b/src/backend/InvenTree/stock/status_codes.py @@ -3,23 +3,28 @@ from django.utils.translation import gettext_lazy as _ from generic.states import StatusCode +from generic.states.states import ColorEnum 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/templates/js/translated/build.js b/src/backend/InvenTree/templates/js/translated/build.js index 5901ce820cfb..d2c1505fcfab 100644 --- a/src/backend/InvenTree/templates/js/translated/build.js +++ b/src/backend/InvenTree/templates/js/translated/build.js @@ -612,7 +612,7 @@ function completeBuildOutputs(build_id, outputs, options={}) { method: 'POST', preFormContent: html, fields: { - status: {}, + status_custom_key: {}, location: { filters: { structural: false, @@ -641,7 +641,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), @@ -1150,7 +1150,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', }); } @@ -1553,7 +1553,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; } @@ -2359,7 +2359,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 3875926c382f..e66699d4bb3c 100644 --- a/src/backend/InvenTree/templates/js/translated/part.js +++ b/src/backend/InvenTree/templates/js/translated/part.js @@ -1758,7 +1758,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 7a0cd636aa4b..ff96c3973491 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', From 4cee20f633199ee204ff2f267b65abddc92f081d Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Fri, 9 Aug 2024 00:09:48 +0200 Subject: [PATCH 02/59] make tests more reliable --- src/backend/InvenTree/plugin/base/label/test_label_mixin.py | 1 + src/backend/InvenTree/report/tests.py | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) 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 68120a01a24e..8a43a60a139b 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() From e7ee679008315294108719a6d76adcfc806dffe1 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Fri, 9 Aug 2024 00:33:15 +0200 Subject: [PATCH 03/59] fix list options --- src/backend/InvenTree/InvenTree/serializers.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/backend/InvenTree/InvenTree/serializers.py b/src/backend/InvenTree/InvenTree/serializers.py index aebe193bb824..0ecfca9ab373 100644 --- a/src/backend/InvenTree/InvenTree/serializers.py +++ b/src/backend/InvenTree/InvenTree/serializers.py @@ -298,9 +298,9 @@ def build_standard_field(self, field_name, model_field): # Inherit choices from leader self.gather_custom_fields() if field_name in self._custom_fields: - field_kwargs['choices'] = self.fields[ - field_name.replace('_custom_key', '') - ].choices + field_kwargs['choices'] = list( + self.fields[field_name.replace('_custom_key', '')].choices.items() + ) else: field_kwargs['choices'] = [] return field_cls, field_kwargs From a9239e3d5ef83101a5dc7b08284193e3ba54b3ac Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Fri, 9 Aug 2024 00:46:25 +0200 Subject: [PATCH 04/59] Adapt version --- src/backend/InvenTree/InvenTree/api_version.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index f4ca3b8293e0..9993d684f5e6 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 = 234 +INVENTREE_API_VERSION = 235 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v235 - 2024-08-09 : https://github.com/inventree/InvenTree/pull/ + - Added custom status fields + - Added endpoints to admin custom status fields + v234 - 2024-08-08 : https://github.com/inventree/InvenTree/pull/7829 - Fixes bug in the plugin metadata endpoint From acf6e11f0b903d77eded6f8364811edfbf797938 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Fri, 9 Aug 2024 01:02:24 +0200 Subject: [PATCH 05/59] do not engage if rebuilding --- src/backend/InvenTree/InvenTree/context.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/backend/InvenTree/InvenTree/context.py b/src/backend/InvenTree/InvenTree/context.py index 1988536f2a0a..78a94431306b 100644 --- a/src/backend/InvenTree/InvenTree/context.py +++ b/src/backend/InvenTree/InvenTree/context.py @@ -3,6 +3,7 @@ """Provides extra global data to all templates.""" import InvenTree.email +import InvenTree.ready import InvenTree.status from generic.states.custom import get_custom_classes from users.models import RuleSet, check_user_role @@ -52,7 +53,10 @@ def status_codes(request): return {} request._inventree_status_codes = True - return {cls.__name__: cls.template_context() for cls in get_custom_classes()} + 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): From 451362d546266d20f0dbe3c4e013b04e744a7622 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Fri, 9 Aug 2024 01:02:47 +0200 Subject: [PATCH 06/59] remove unneeded attr --- src/backend/InvenTree/InvenTree/generic_fields.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/backend/InvenTree/InvenTree/generic_fields.py b/src/backend/InvenTree/InvenTree/generic_fields.py index 0fb97a31f2e5..5baefaa4f942 100644 --- a/src/backend/InvenTree/InvenTree/generic_fields.py +++ b/src/backend/InvenTree/InvenTree/generic_fields.py @@ -17,6 +17,7 @@ def __init__(self, choices: Iterable, **kwargs): 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) super().__init__(choices, **kwargs) self.choice_mdl = choice_mdl self.choice_field = choice_field From c0ee300a269dfbbcd4c742d949fdd6fd553b75a3 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Fri, 9 Aug 2024 01:16:11 +0200 Subject: [PATCH 07/59] remove unneeded attr --- src/backend/InvenTree/InvenTree/generic_fields.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/backend/InvenTree/InvenTree/generic_fields.py b/src/backend/InvenTree/InvenTree/generic_fields.py index 5baefaa4f942..3fee584e3e83 100644 --- a/src/backend/InvenTree/InvenTree/generic_fields.py +++ b/src/backend/InvenTree/InvenTree/generic_fields.py @@ -18,6 +18,7 @@ def __init__(self, choices: Iterable, **kwargs): 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 From dbf9db5a0005d5a1fa45e6096d22bdf16b897c3a Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Fri, 9 Aug 2024 12:53:12 +0200 Subject: [PATCH 08/59] fix enum imports --- src/backend/InvenTree/build/status_codes.py | 3 +-- src/backend/InvenTree/common/models.py | 2 +- .../InvenTree/generic/states/__init__.py | 10 ++++++++-- src/backend/InvenTree/generic/states/tests.py | 2 +- .../InvenTree/importer/status_codes.py | 20 +++++++++++++------ .../machine/machine_types/label_printer.py | 13 ++++++------ src/backend/InvenTree/order/status_codes.py | 3 +-- src/backend/InvenTree/stock/status_codes.py | 3 +-- 8 files changed, 34 insertions(+), 22 deletions(-) diff --git a/src/backend/InvenTree/build/status_codes.py b/src/backend/InvenTree/build/status_codes.py index 7ccb81b98912..bc7fc47ddc9b 100644 --- a/src/backend/InvenTree/build/status_codes.py +++ b/src/backend/InvenTree/build/status_codes.py @@ -2,8 +2,7 @@ from django.utils.translation import gettext_lazy as _ -from generic.states import StatusCode -from generic.states.states import ColorEnum +from generic.states import ColorEnum, StatusCode class BuildStatus(StatusCode): diff --git a/src/backend/InvenTree/common/models.py b/src/backend/InvenTree/common/models.py index c49330d95c90..291e9c4dfdf0 100644 --- a/src/backend/InvenTree/common/models.py +++ b/src/backend/InvenTree/common/models.py @@ -53,7 +53,7 @@ import plugin.base.barcodes.helper import report.helpers import users.models -from generic.states.states import ColorEnum, StatusCode +from generic.states import ColorEnum, StatusCode from InvenTree.helpers import inheritors from InvenTree.sanitizer import sanitize_svg from plugin import registry 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/tests.py b/src/backend/InvenTree/generic/states/tests.py index 1a9582cfb810..f6be301c3f9e 100644 --- a/src/backend/InvenTree/generic/states/tests.py +++ b/src/backend/InvenTree/generic/states/tests.py @@ -5,7 +5,7 @@ from rest_framework.test import force_authenticate -from generic.states.states import ColorEnum +from generic.states import ColorEnum from InvenTree.unit_test import InvenTreeTestCase from .api import StatusView 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/status_codes.py b/src/backend/InvenTree/order/status_codes.py index 2f3a3e92d922..3dcbee01f518 100644 --- a/src/backend/InvenTree/order/status_codes.py +++ b/src/backend/InvenTree/order/status_codes.py @@ -2,8 +2,7 @@ from django.utils.translation import gettext_lazy as _ -from generic.states import StatusCode -from generic.states.states import ColorEnum +from generic.states import ColorEnum, StatusCode class PurchaseOrderStatus(StatusCode): diff --git a/src/backend/InvenTree/stock/status_codes.py b/src/backend/InvenTree/stock/status_codes.py index 23effb8f9b8c..d59d6225694a 100644 --- a/src/backend/InvenTree/stock/status_codes.py +++ b/src/backend/InvenTree/stock/status_codes.py @@ -2,8 +2,7 @@ from django.utils.translation import gettext_lazy as _ -from generic.states import StatusCode -from generic.states.states import ColorEnum +from generic.states import ColorEnum, StatusCode class StockStatus(StatusCode): From 049f9bd994241668acf9ef885bb552569b8c60a6 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Sun, 11 Aug 2024 21:39:15 +0200 Subject: [PATCH 09/59] adapt cove target --- .github/workflows/qc_checks.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/qc_checks.yaml b/.github/workflows/qc_checks.yaml index a128af69c8ac..0a21080f1c62 100644 --- a/.github/workflows/qc_checks.yaml +++ b/.github/workflows/qc_checks.yaml @@ -312,7 +312,7 @@ jobs: if: always() with: token: ${{ secrets.CODECOV_TOKEN }} - slug: inventree/InvenTree + slug: matmair/InvenTree flags: backend postgres: @@ -444,7 +444,7 @@ jobs: if: always() with: token: ${{ secrets.CODECOV_TOKEN }} - slug: inventree/InvenTree + slug: matmair/InvenTree flags: migrations migrations-checks: @@ -549,7 +549,7 @@ jobs: if: always() with: token: ${{ secrets.CODECOV_TOKEN }} - slug: inventree/InvenTree + slug: matmair/InvenTree flags: pui platform_ui_build: From c5268f62e6c2fe68e704492f95d532a8377e9dc2 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Mon, 12 Aug 2024 01:35:27 +0200 Subject: [PATCH 10/59] Add status_custom_key to all other serializers --- src/backend/InvenTree/build/serializers.py | 5 +++-- src/backend/InvenTree/order/serializers.py | 20 +++++++++++++++++--- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/src/backend/InvenTree/build/serializers.py b/src/backend/InvenTree/build/serializers.py index 38f6b43adf59..18de4ac5cffa 100644 --- a/src/backend/InvenTree/build/serializers.py +++ b/src/backend/InvenTree/build/serializers.py @@ -15,7 +15,7 @@ from rest_framework import serializers from rest_framework.serializers import ValidationError -from InvenTree.serializers import InvenTreeModelSerializer, UserSerializer +from InvenTree.serializers import InvenTreeModelSerializer, UserSerializer, InvenTreeCustomStatusSerializerMixin import InvenTree.helpers from InvenTree.serializers import InvenTreeDecimalField, NotesFieldMixin @@ -37,7 +37,7 @@ from .status_codes import BuildStatus -class BuildSerializer(NotesFieldMixin, DataImportExportSerializerMixin, InvenTreeModelSerializer): +class BuildSerializer(NotesFieldMixin, DataImportExportSerializerMixin, InvenTreeCustomStatusSerializerMixin, InvenTreeModelSerializer): """Serializes a Build object.""" class Meta: @@ -66,6 +66,7 @@ class Meta: 'quantity', 'status', 'status_text', + 'status_custom_key', 'target_date', 'take_from', 'notes', diff --git a/src/backend/InvenTree/order/serializers.py b/src/backend/InvenTree/order/serializers.py index f24b009e38ee..b34969fb0385 100644 --- a/src/backend/InvenTree/order/serializers.py +++ b/src/backend/InvenTree/order/serializers.py @@ -43,6 +43,7 @@ ) from InvenTree.serializers import ( InvenTreeCurrencySerializer, + InvenTreeCustomStatusSerializerMixin, InvenTreeDecimalField, InvenTreeModelSerializer, InvenTreeMoneySerializer, @@ -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.""" @@ -860,7 +866,11 @@ def save(self): @register_importer() class SalesOrderSerializer( - NotesFieldMixin, TotalPriceMixin, AbstractOrderSerializer, InvenTreeModelSerializer + NotesFieldMixin, + TotalPriceMixin, + InvenTreeCustomStatusSerializerMixin, + AbstractOrderSerializer, + InvenTreeModelSerializer, ): """Serializer for the SalesOrder model class.""" @@ -1643,7 +1653,11 @@ class Meta(AbstractExtraLineMeta): @register_importer() class ReturnOrderSerializer( - NotesFieldMixin, AbstractOrderSerializer, TotalPriceMixin, InvenTreeModelSerializer + NotesFieldMixin, + InvenTreeCustomStatusSerializerMixin, + AbstractOrderSerializer, + TotalPriceMixin, + InvenTreeModelSerializer, ): """Serializer for the ReturnOrder model class.""" From 77dc0c795f7fba1afb36d5b080672ed852fc086c Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Mon, 12 Aug 2024 01:36:28 +0200 Subject: [PATCH 11/59] fix serializer method --- src/backend/InvenTree/InvenTree/serializers.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/backend/InvenTree/InvenTree/serializers.py b/src/backend/InvenTree/InvenTree/serializers.py index 0ecfca9ab373..1b3c937c46a3 100644 --- a/src/backend/InvenTree/InvenTree/serializers.py +++ b/src/backend/InvenTree/InvenTree/serializers.py @@ -298,9 +298,11 @@ def build_standard_field(self, field_name, model_field): # Inherit choices from leader self.gather_custom_fields() if field_name in self._custom_fields: - field_kwargs['choices'] = list( - self.fields[field_name.replace('_custom_key', '')].choices.items() - ) + leader_field = self.fields[field_name.replace('_custom_key', '')] + if hasattr(leader_field, 'choices'): + field_kwargs['choices'] = list(leader_field.choices.items()) + else: + field_kwargs['choices'] = [] else: field_kwargs['choices'] = [] return field_cls, field_kwargs From 3e803c4082d1798dad02e5e8a5aac02115cbbe93 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Mon, 12 Aug 2024 01:37:38 +0200 Subject: [PATCH 12/59] simplify branching --- src/backend/InvenTree/InvenTree/serializers.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/backend/InvenTree/InvenTree/serializers.py b/src/backend/InvenTree/InvenTree/serializers.py index 1b3c937c46a3..6a17e0780423 100644 --- a/src/backend/InvenTree/InvenTree/serializers.py +++ b/src/backend/InvenTree/InvenTree/serializers.py @@ -301,10 +301,10 @@ def build_standard_field(self, field_name, model_field): leader_field = self.fields[field_name.replace('_custom_key', '')] if hasattr(leader_field, 'choices'): field_kwargs['choices'] = list(leader_field.choices.items()) - else: - field_kwargs['choices'] = [] - else: + + if not field_kwargs.get('choices', None): field_kwargs['choices'] = [] + return field_cls, field_kwargs From 0e3d2f3fab34ec621350fd788a209fb236c1dc09 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Mon, 12 Aug 2024 01:37:55 +0200 Subject: [PATCH 13/59] remove unneeded imports --- src/backend/InvenTree/InvenTree/models.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/backend/InvenTree/InvenTree/models.py b/src/backend/InvenTree/InvenTree/models.py index c08c63682e24..c9f7a31253bc 100644 --- a/src/backend/InvenTree/InvenTree/models.py +++ b/src/backend/InvenTree/InvenTree/models.py @@ -21,8 +21,6 @@ import InvenTree.format import InvenTree.helpers import InvenTree.helpers_model -from generic.states.states import ColorEnum, StatusCode -from InvenTree.helpers import inheritors logger = logging.getLogger('inventree') From c81213afe525168c9e49c4ec4c0e477224264086 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Mon, 12 Aug 2024 08:27:02 +0200 Subject: [PATCH 14/59] inherit read_only status from leader field --- src/backend/InvenTree/InvenTree/serializers.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/backend/InvenTree/InvenTree/serializers.py b/src/backend/InvenTree/InvenTree/serializers.py index 6a17e0780423..2e505a522fab 100644 --- a/src/backend/InvenTree/InvenTree/serializers.py +++ b/src/backend/InvenTree/InvenTree/serializers.py @@ -301,6 +301,8 @@ def build_standard_field(self, field_name, model_field): leader_field = self.fields[field_name.replace('_custom_key', '')] if hasattr(leader_field, 'choices'): field_kwargs['choices'] = list(leader_field.choices.items()) + if getattr(leader_field, 'read_only', False) is True: + field_kwargs['read_only'] = True if not field_kwargs.get('choices', None): field_kwargs['choices'] = [] From c9c01bebb7c2c92d084ab30477287b84a5556ae9 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Mon, 12 Aug 2024 10:41:37 +0200 Subject: [PATCH 15/59] Add more tests --- src/backend/InvenTree/common/models.py | 5 ++ src/backend/InvenTree/common/tests.py | 87 ++++++++++++++++++++++++++ 2 files changed, 92 insertions(+) diff --git a/src/backend/InvenTree/common/models.py b/src/backend/InvenTree/common/models.py index 291e9c4dfdf0..41a5ae2c9cf6 100644 --- a/src/backend/InvenTree/common/models.py +++ b/src/backend/InvenTree/common/models.py @@ -3394,6 +3394,11 @@ def __str__(self) -> str: """Return string representation of the custom state.""" return f'{self.model} ({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: diff --git a/src/backend/InvenTree/common/tests.py b/src/backend/InvenTree/common/tests.py index 0c4d236bf9f7..b0a9b1ed0407 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, @@ -1566,3 +1567,89 @@ 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.datadata.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) From 5926fc7711611e8d8a0cf1db83ae7d52af3b2fdc Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Mon, 12 Aug 2024 11:35:09 +0200 Subject: [PATCH 16/59] fix tests --- src/backend/InvenTree/common/tests.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/backend/InvenTree/common/tests.py b/src/backend/InvenTree/common/tests.py index b0a9b1ed0407..fba6b781f026 100644 --- a/src/backend/InvenTree/common/tests.py +++ b/src/backend/InvenTree/common/tests.py @@ -1602,7 +1602,8 @@ def test_validation_key(self): def test_validation_logicalkey(self): """Tests Logical key must be present.""" - data = self.datadata.pop('logical_key') + data = self.data + data.pop('logical_key') with self.assertRaises(ValidationError): InvenTreeCustomUserStateModel.objects.create(**data) self.assertEqual(InvenTreeCustomUserStateModel.objects.count(), 0) From 04498e906399c7a1f8692eebd59d08a2e234b6ee Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Mon, 12 Aug 2024 12:25:34 +0200 Subject: [PATCH 17/59] add test for function --- .../InvenTree/InvenTree/serializers.py | 8 +++-- src/backend/InvenTree/stock/test_api.py | 31 ++++++++++++++++++- 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/src/backend/InvenTree/InvenTree/serializers.py b/src/backend/InvenTree/InvenTree/serializers.py index 2e505a522fab..23d4c44823bd 100644 --- a/src/backend/InvenTree/InvenTree/serializers.py +++ b/src/backend/InvenTree/InvenTree/serializers.py @@ -221,9 +221,11 @@ class InvenTreeCustomStatusSerializerMixin: def create(self, validated_data): """Ensure the custom field is set when the leader is set initially.""" self.gather_custom_fields() - for field in self._custom_fields_leader: - if field in self.initial_data: - validated_data[f'{field}_custom_key'] = int(self.initial_data[field]) + for field in self._custom_fields_follower: + if field not in self.initial_data: + validated_data[field] = int( + self.initial_data[field.replace('_custom_key', '')] + ) return super().create(validated_data) def update(self, instance, validated_data): diff --git a/src/backend/InvenTree/stock/test_api.py b/src/backend/InvenTree/stock/test_api.py index 61a8a43ba73d..e7f9a7a7d529 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 ( @@ -924,6 +925,34 @@ def test_query_count(self): self.list_url, {'location_detail': True, 'tests': True}, max_query_count=35 ) + def test_custom_status(self): + """Tests custom stock status codes.""" + # Create a custom stock status code + 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', + ) + + # 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': status.key, + }, + expected_code=201, + ) + self.assertEqual(response.data['status'], status.logical_key) + self.assertEqual(response.data['status_custom_key'], status.key) + class StockItemTest(StockAPITestCase): """Series of API tests for the StockItem API.""" From 67dde3881f56a16dd284b4439f041458e736e621 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Mon, 12 Aug 2024 18:37:26 +0200 Subject: [PATCH 18/59] refactor for easier testing --- .../InvenTree/InvenTree/generic_fields.py | 17 ++++++------ .../InvenTree/InvenTree/serializers.py | 11 ++++++++ src/backend/InvenTree/stock/test_api.py | 27 +++++++++++++++++++ 3 files changed, 46 insertions(+), 9 deletions(-) diff --git a/src/backend/InvenTree/InvenTree/generic_fields.py b/src/backend/InvenTree/InvenTree/generic_fields.py index 3fee584e3e83..8a4801da5cb5 100644 --- a/src/backend/InvenTree/InvenTree/generic_fields.py +++ b/src/backend/InvenTree/InvenTree/generic_fields.py @@ -9,6 +9,13 @@ from rest_framework import serializers +def get_logical_value(value, model: str): + """Get the state model for the selected value.""" + from common.models import InvenTreeCustomUserStateModel + + return InvenTreeCustomUserStateModel.objects.get(key=value, model__model=model) + + class CustomChoiceField(serializers.ChoiceField): """Custom Choice Field.""" @@ -24,14 +31,6 @@ def __init__(self, choices: Iterable, **kwargs): self.choice_field = choice_field self.is_is_custom = is_custom - def get_logical_value(self, value): - """Get the state model for the selected value.""" - from common.models import InvenTreeCustomUserStateModel - - return InvenTreeCustomUserStateModel.objects.get( - key=value, model__model=self.choice_mdl._meta.model_name - ) - def to_internal_value(self, data): """Map the choice (that might be a custom one) back to the logical value.""" try: @@ -40,7 +39,7 @@ def to_internal_value(self, data): from common.models import InvenTreeCustomUserStateModel try: - logical = self.get_logical_value(data) + logical = get_logical_value(data, self.choice_mdl._meta.model_name) if self.is_is_custom: return logical.key return logical.logical_key diff --git a/src/backend/InvenTree/InvenTree/serializers.py b/src/backend/InvenTree/InvenTree/serializers.py index 23d4c44823bd..f26346e23e91 100644 --- a/src/backend/InvenTree/InvenTree/serializers.py +++ b/src/backend/InvenTree/InvenTree/serializers.py @@ -32,6 +32,7 @@ ExtraCustomChoiceField, InvenTreeCustomStatusExtraModelField, InvenTreeCustomStatusModelField, + get_logical_value, ) @@ -239,6 +240,16 @@ def update(self, instance, validated_data): != 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): diff --git a/src/backend/InvenTree/stock/test_api.py b/src/backend/InvenTree/stock/test_api.py index e7f9a7a7d529..153726e1cadd 100644 --- a/src/backend/InvenTree/stock/test_api.py +++ b/src/backend/InvenTree/stock/test_api.py @@ -953,6 +953,33 @@ def test_custom_status(self): self.assertEqual(response.data['status'], status.logical_key) self.assertEqual(response.data['status_custom_key'], status.key) + # Update the stock item with another custom status code via the API + 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', + ) + response2 = self.patch( + reverse('api-stock-detail', kwargs={'pk': response.data['pk']}), + {'status_custom_key': status2.key}, + expected_code=200, + ) + self.assertEqual(response2.data['status'], status2.logical_key) + self.assertEqual(response2.data['status_custom_key'], status2.key) + + # Try if status_custom_key is rewrite with status bying set + response3 = self.patch( + reverse('api-stock-detail', kwargs={'pk': response.data['pk']}), + {'status': status.logical_key}, + expected_code=200, + ) + self.assertEqual(response3.data['status'], status.logical_key) + self.assertEqual(response3.data['status_custom_key'], status.logical_key) + class StockItemTest(StockAPITestCase): """Series of API tests for the StockItem API.""" From 94858a42eb70e139815c5f6a7b66fb845406be85 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Mon, 12 Aug 2024 18:53:19 +0200 Subject: [PATCH 19/59] move test to seperate class --- src/backend/InvenTree/stock/test_api.py | 51 ++++++++++++++----------- 1 file changed, 29 insertions(+), 22 deletions(-) diff --git a/src/backend/InvenTree/stock/test_api.py b/src/backend/InvenTree/stock/test_api.py index 153726e1cadd..fc517a9b0a4c 100644 --- a/src/backend/InvenTree/stock/test_api.py +++ b/src/backend/InvenTree/stock/test_api.py @@ -925,10 +925,15 @@ def test_query_count(self): self.list_url, {'location_detail': True, 'tests': True}, max_query_count=35 ) - def test_custom_status(self): - """Tests custom stock status codes.""" - # Create a custom stock status code - status = InvenTreeCustomUserStateModel.objects.create( + +class CustomStockItemStatusTest(StockAPITestCase): + """Tests for custom stock item statuses.""" + + list_url = reverse('api-stock-list') + + def setUp(self): + """Setup for all tests.""" + self.status = InvenTreeCustomUserStateModel.objects.create( key=11, name='OK - advanced', label='OK - adv.', @@ -937,7 +942,18 @@ def test_custom_status(self): 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, @@ -946,39 +962,30 @@ def test_custom_status(self): 'description': 'Test desc 1', 'quantity': 1, 'part': 1, - 'status_custom_key': status.key, + 'status_custom_key': self.status.key, }, expected_code=201, ) - self.assertEqual(response.data['status'], status.logical_key) - self.assertEqual(response.data['status_custom_key'], status.key) + self.assertEqual(response.data['status'], self.status.logical_key) + self.assertEqual(response.data['status_custom_key'], self.status.key) # Update the stock item with another custom status code via the API - 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', - ) response2 = self.patch( reverse('api-stock-detail', kwargs={'pk': response.data['pk']}), - {'status_custom_key': status2.key}, + {'status_custom_key': self.status2.key}, expected_code=200, ) - self.assertEqual(response2.data['status'], status2.logical_key) - self.assertEqual(response2.data['status_custom_key'], status2.key) + self.assertEqual(response2.data['status'], self.status2.logical_key) + self.assertEqual(response2.data['status_custom_key'], self.status2.key) # Try if status_custom_key is rewrite with status bying set response3 = self.patch( reverse('api-stock-detail', kwargs={'pk': response.data['pk']}), - {'status': status.logical_key}, + {'status': self.status.logical_key}, expected_code=200, ) - self.assertEqual(response3.data['status'], status.logical_key) - self.assertEqual(response3.data['status_custom_key'], status.logical_key) + self.assertEqual(response3.data['status'], self.status.logical_key) + self.assertEqual(response3.data['status_custom_key'], self.status.logical_key) class StockItemTest(StockAPITestCase): From b4e42da5eb71ac61c32fcfb50428f34a725268d4 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Mon, 12 Aug 2024 21:01:54 +0200 Subject: [PATCH 20/59] Add options testing --- src/backend/InvenTree/stock/test_api.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/backend/InvenTree/stock/test_api.py b/src/backend/InvenTree/stock/test_api.py index fc517a9b0a4c..6302acaac496 100644 --- a/src/backend/InvenTree/stock/test_api.py +++ b/src/backend/InvenTree/stock/test_api.py @@ -933,6 +933,7 @@ class CustomStockItemStatusTest(StockAPITestCase): def setUp(self): """Setup for all tests.""" + super().setUp() self.status = InvenTreeCustomUserStateModel.objects.create( key=11, name='OK - advanced', @@ -987,6 +988,21 @@ def test_custom_status(self): self.assertEqual(response3.data['status'], self.status.logical_key) self.assertEqual(response3.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.""" From 6a5f9c2b5d85d9e491362731adc1251717d31683 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Mon, 12 Aug 2024 21:27:58 +0200 Subject: [PATCH 21/59] extend serializer --- src/backend/InvenTree/stock/serializers.py | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/backend/InvenTree/stock/serializers.py b/src/backend/InvenTree/stock/serializers.py index 2625db838e8f..e9f2ececf70b 100644 --- a/src/backend/InvenTree/stock/serializers.py +++ b/src/backend/InvenTree/stock/serializers.py @@ -29,7 +29,11 @@ from common.settings import get_global_setting from importer.mixins import DataImportExportSerializerMixin from importer.registry import register_importer -from InvenTree.serializers import InvenTreeCurrencySerializer, InvenTreeDecimalField +from InvenTree.serializers import ( + InvenTreeCurrencySerializer, + InvenTreeCustomStatusSerializerMixin, + InvenTreeDecimalField, +) from .models import ( StockItem, @@ -1215,7 +1219,9 @@ def annotate_queryset(queryset): @register_importer() class StockTrackingSerializer( - DataImportExportSerializerMixin, InvenTree.serializers.InvenTreeModelSerializer + DataImportExportSerializerMixin, + InvenTreeCustomStatusSerializerMixin, + InvenTree.serializers.InvenTreeModelSerializer, ): """Serializer for StockItemTracking model.""" @@ -1232,11 +1238,18 @@ class Meta: 'label', 'notes', 'tracking_type', + 'tracking_type_custom_key', 'user', 'user_detail', ] - read_only_fields = ['date', 'user', 'label', 'tracking_type'] + read_only_fields = [ + 'date', + 'user', + 'label', + 'tracking_type', + 'tracking_type_custom_key', + ] def __init__(self, *args, **kwargs): """Add detail fields.""" From cf9dcf29b4a9bd50c0e6dd2665f2daa77b12fe14 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Mon, 12 Aug 2024 22:08:40 +0200 Subject: [PATCH 22/59] add test for all states and refactor to reuse already build functions --- src/backend/InvenTree/generic/states/api.py | 19 ++----- src/backend/InvenTree/generic/states/tests.py | 50 ++++++++++++++++++- 2 files changed, 54 insertions(+), 15 deletions(-) diff --git a/src/backend/InvenTree/generic/states/api.py b/src/backend/InvenTree/generic/states/api.py index 203a6dea3abf..87ca58daa3d6 100644 --- a/src/backend/InvenTree/generic/states/api.py +++ b/src/backend/InvenTree/generic/states/api.py @@ -7,6 +7,7 @@ from rest_framework.generics import GenericAPIView from rest_framework.response import Response +from generic.states.custom import get_custom_classes from InvenTree.serializers import EmptySerializer from .states import StatusCode @@ -73,18 +74,8 @@ class AllStatusViews(StatusView): def get(self, request, *args, **kwargs): """Perform a GET request to learn information about status codes.""" - data = {} - - def discover_status_codes(parent_status_class, prefix=None): - """Recursively discover status classes.""" - for status_class in parent_status_class.__subclasses__(): - name = '__'.join([*(prefix or []), status_class.__name__]) - data[name] = { - 'class': status_class.__name__, - 'values': status_class.dict(), - } - discover_status_codes(status_class, [name]) - - discover_status_codes(StatusCode) - + data = { + k.__name__: {'class': k.__name__, 'values': k.dict()} + for k in get_custom_classes() + } return Response(data) diff --git a/src/backend/InvenTree/generic/states/tests.py b/src/backend/InvenTree/generic/states/tests.py index f6be301c3f9e..cf7fa5a72b46 100644 --- a/src/backend/InvenTree/generic/states/tests.py +++ b/src/backend/InvenTree/generic/states/tests.py @@ -1,12 +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 common.models import InvenTreeCustomUserStateModel from generic.states import ColorEnum -from InvenTree.unit_test import InvenTreeTestCase +from InvenTree.unit_test import InvenTreeAPITestCase, InvenTreeTestCase from .api import StatusView from .states import StatusCode @@ -192,3 +195,48 @@ 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')) + + # 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') + + # 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')) + 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') From 06886eca736eb2689f01c229172d66e81411876f Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Mon, 12 Aug 2024 22:11:51 +0200 Subject: [PATCH 23/59] use custom field in PUI too --- src/frontend/src/pages/build/BuildDetail.tsx | 2 +- src/frontend/src/pages/purchasing/PurchaseOrderDetail.tsx | 2 +- src/frontend/src/pages/sales/ReturnOrderDetail.tsx | 2 +- src/frontend/src/pages/sales/SalesOrderDetail.tsx | 2 +- src/frontend/src/pages/stock/StockDetail.tsx | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/frontend/src/pages/build/BuildDetail.tsx b/src/frontend/src/pages/build/BuildDetail.tsx index 062fbf07894b..c9120f3f67ad 100644 --- a/src/frontend/src/pages/build/BuildDetail.tsx +++ b/src/frontend/src/pages/build/BuildDetail.tsx @@ -511,7 +511,7 @@ export default function BuildDetail() { ? [] : [ diff --git a/src/frontend/src/pages/purchasing/PurchaseOrderDetail.tsx b/src/frontend/src/pages/purchasing/PurchaseOrderDetail.tsx index 1eda9953057a..ec7ab5ce3ca5 100644 --- a/src/frontend/src/pages/purchasing/PurchaseOrderDetail.tsx +++ b/src/frontend/src/pages/purchasing/PurchaseOrderDetail.tsx @@ -418,7 +418,7 @@ export default function PurchaseOrderDetail() { ? [] : [ diff --git a/src/frontend/src/pages/sales/ReturnOrderDetail.tsx b/src/frontend/src/pages/sales/ReturnOrderDetail.tsx index d685862b26e8..504ec94526ff 100644 --- a/src/frontend/src/pages/sales/ReturnOrderDetail.tsx +++ b/src/frontend/src/pages/sales/ReturnOrderDetail.tsx @@ -260,7 +260,7 @@ export default function ReturnOrderDetail() { ? [] : [ diff --git a/src/frontend/src/pages/sales/SalesOrderDetail.tsx b/src/frontend/src/pages/sales/SalesOrderDetail.tsx index e2e38de37e88..21a22798b945 100644 --- a/src/frontend/src/pages/sales/SalesOrderDetail.tsx +++ b/src/frontend/src/pages/sales/SalesOrderDetail.tsx @@ -457,7 +457,7 @@ export default function SalesOrderDetail() { ? [] : [ , Date: Mon, 12 Aug 2024 23:28:55 +0200 Subject: [PATCH 24/59] reset diff --- .github/workflows/qc_checks.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/qc_checks.yaml b/.github/workflows/qc_checks.yaml index 0a21080f1c62..a128af69c8ac 100644 --- a/.github/workflows/qc_checks.yaml +++ b/.github/workflows/qc_checks.yaml @@ -312,7 +312,7 @@ jobs: if: always() with: token: ${{ secrets.CODECOV_TOKEN }} - slug: matmair/InvenTree + slug: inventree/InvenTree flags: backend postgres: @@ -444,7 +444,7 @@ jobs: if: always() with: token: ${{ secrets.CODECOV_TOKEN }} - slug: matmair/InvenTree + slug: inventree/InvenTree flags: migrations migrations-checks: @@ -549,7 +549,7 @@ jobs: if: always() with: token: ${{ secrets.CODECOV_TOKEN }} - slug: matmair/InvenTree + slug: inventree/InvenTree flags: pui platform_ui_build: From d62f9b80295d3f3b822649b2917137cea05b1d65 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Mon, 12 Aug 2024 23:48:31 +0200 Subject: [PATCH 25/59] style fix --- src/backend/InvenTree/InvenTree/api_version.py | 4 ++-- src/backend/InvenTree/stock/models.py | 2 -- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index c56cfe9382f6..7a35a2cc4a76 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -9,8 +9,8 @@ INVENTREE_API_TEXT = """ v237 - 2024-08-09 : https://github.com/inventree/InvenTree/pull/7862 - - Added custom status fields to various serializers - - Added endpoints to admin custom status fields + - Adds custom status fields to various serializers + - Adds endpoints to admin custom status fields v236 - 2024-08-10 : https://github.com/inventree/InvenTree/pull/7844 - Adds "supplier_name" to the PurchaseOrder API serializer diff --git a/src/backend/InvenTree/stock/models.py b/src/backend/InvenTree/stock/models.py index 759d670b9f45..ce1587f7a563 100644 --- a/src/backend/InvenTree/stock/models.py +++ b/src/backend/InvenTree/stock/models.py @@ -45,12 +45,10 @@ StockStatus, StockStatusGroups, ) -from order.status_codes import SalesOrderStatusGroups 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') From 198bd8f4be83acb5bc2419788e8d55bee3d2fd94 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Tue, 13 Aug 2024 00:46:10 +0200 Subject: [PATCH 26/59] fix comparison --- src/backend/InvenTree/common/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/backend/InvenTree/common/models.py b/src/backend/InvenTree/common/models.py index a2961001ff1f..bf495e2581d0 100644 --- a/src/backend/InvenTree/common/models.py +++ b/src/backend/InvenTree/common/models.py @@ -3422,7 +3422,7 @@ def clean(self) -> None: if self.key == self.logical_key: raise ValidationError({'key': _('Key must be different from logical key')}) - if self.reference_status is None: + if self.reference_status is None or self.reference_status == '': raise ValidationError({ 'reference_status': _('Reference status must be selected') }) From d0409eeef1274d26e015c06177e8ba6d4bc4a515 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Tue, 13 Aug 2024 00:49:47 +0200 Subject: [PATCH 27/59] Add test for str --- src/backend/InvenTree/common/models.py | 2 +- src/backend/InvenTree/common/tests.py | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/backend/InvenTree/common/models.py b/src/backend/InvenTree/common/models.py index bf495e2581d0..aaf4bcffb845 100644 --- a/src/backend/InvenTree/common/models.py +++ b/src/backend/InvenTree/common/models.py @@ -3400,7 +3400,7 @@ class Meta: def __str__(self) -> str: """Return string representation of the custom state.""" - return f'{self.model} ({self.reference_status}): {self.name} | {self.key} ({self.logical_key})' + 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.""" diff --git a/src/backend/InvenTree/common/tests.py b/src/backend/InvenTree/common/tests.py index 1a07560c1bc9..3cf8285d0337 100644 --- a/src/backend/InvenTree/common/tests.py +++ b/src/backend/InvenTree/common/tests.py @@ -1651,3 +1651,6 @@ def test_validation(self): 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)' + ) From 33da48cbb02d8973483c81eb8c82aba36a70a425 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Tue, 13 Aug 2024 00:57:09 +0200 Subject: [PATCH 28/59] test color exceptions too --- src/backend/InvenTree/generic/states/tests.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/backend/InvenTree/generic/states/tests.py b/src/backend/InvenTree/generic/states/tests.py index cf7fa5a72b46..5b77bcfd3873 100644 --- a/src/backend/InvenTree/generic/states/tests.py +++ b/src/backend/InvenTree/generic/states/tests.py @@ -124,6 +124,17 @@ def test_code_functions(self): # label self.assertEqual(GeneralStatus.label(10), 'Pending') + 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_function(self): """Test that the status code tag functions.""" from .tags import status_label From 83c91f16e4432cefdfa60c280c0b7d8fdd054c1e Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Tue, 13 Aug 2024 08:00:24 +0200 Subject: [PATCH 29/59] remove user state from tracking --- ...13_stockitem_status_custom_key_and_more.py | 18 ------------------ src/backend/InvenTree/stock/models.py | 2 +- src/backend/InvenTree/stock/serializers.py | 19 +++---------------- 3 files changed, 4 insertions(+), 35 deletions(-) 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 index 222115ed8b83..1e32ef29bb39 100644 --- 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 @@ -24,17 +24,6 @@ class Migration(migrations.Migration): verbose_name="Custom status key", ), ), - migrations.AddField( - model_name="stockitemtracking", - name="tracking_type_custom_key", - field=InvenTree.generic_fields.InvenTreeCustomStatusExtraModelField( - 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", @@ -44,11 +33,4 @@ class Migration(migrations.Migration): validators=[django.core.validators.MinValueValidator(0)], ), ), - migrations.AlterField( - model_name="stockitemtracking", - name="tracking_type", - field=InvenTree.generic_fields.InvenTreeCustomStatusModelField( - default=stock.status_codes.StockHistoryCode["LEGACY"] - ), - ), ] diff --git a/src/backend/InvenTree/stock/models.py b/src/backend/InvenTree/stock/models.py index ce1587f7a563..1296b762452b 100644 --- a/src/backend/InvenTree/stock/models.py +++ b/src/backend/InvenTree/stock/models.py @@ -2363,7 +2363,7 @@ def label(self): return getattr(self, 'title', '') - tracking_type = InvenTreeCustomStatusModelField(default=StockHistoryCode.LEGACY) + tracking_type = models.IntegerField(default=StockHistoryCode.LEGACY) item = models.ForeignKey( StockItem, on_delete=models.CASCADE, related_name='tracking_info' diff --git a/src/backend/InvenTree/stock/serializers.py b/src/backend/InvenTree/stock/serializers.py index e9f2ececf70b..2625db838e8f 100644 --- a/src/backend/InvenTree/stock/serializers.py +++ b/src/backend/InvenTree/stock/serializers.py @@ -29,11 +29,7 @@ from common.settings import get_global_setting from importer.mixins import DataImportExportSerializerMixin from importer.registry import register_importer -from InvenTree.serializers import ( - InvenTreeCurrencySerializer, - InvenTreeCustomStatusSerializerMixin, - InvenTreeDecimalField, -) +from InvenTree.serializers import InvenTreeCurrencySerializer, InvenTreeDecimalField from .models import ( StockItem, @@ -1219,9 +1215,7 @@ def annotate_queryset(queryset): @register_importer() class StockTrackingSerializer( - DataImportExportSerializerMixin, - InvenTreeCustomStatusSerializerMixin, - InvenTree.serializers.InvenTreeModelSerializer, + DataImportExportSerializerMixin, InvenTree.serializers.InvenTreeModelSerializer ): """Serializer for StockItemTracking model.""" @@ -1238,18 +1232,11 @@ class Meta: 'label', 'notes', 'tracking_type', - 'tracking_type_custom_key', 'user', 'user_detail', ] - read_only_fields = [ - 'date', - 'user', - 'label', - 'tracking_type', - 'tracking_type_custom_key', - ] + read_only_fields = ['date', 'user', 'label', 'tracking_type'] def __init__(self, *args, **kwargs): """Add detail fields.""" From 4d4d3b0d05138ade2d6d4bae5d3762211787fca7 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Tue, 13 Aug 2024 23:50:10 +0200 Subject: [PATCH 30/59] Add intro from model fields too --- src/backend/InvenTree/InvenTree/serializers.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/backend/InvenTree/InvenTree/serializers.py b/src/backend/InvenTree/InvenTree/serializers.py index f26346e23e91..99f253499689 100644 --- a/src/backend/InvenTree/InvenTree/serializers.py +++ b/src/backend/InvenTree/InvenTree/serializers.py @@ -311,13 +311,21 @@ def build_standard_field(self, field_name, model_field): # Inherit choices from leader self.gather_custom_fields() if field_name in self._custom_fields: - leader_field = self.fields[field_name.replace('_custom_key', '')] + 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 not field_kwargs.get('choices', None): + if 'choices' not in field_kwargs: field_kwargs['choices'] = [] return field_cls, field_kwargs From ac0c726553ff5a34a3b7c32eed21ff74f545e1a6 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Wed, 14 Aug 2024 00:00:11 +0200 Subject: [PATCH 31/59] update docs --- docs/docs/extend/machines/overview.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) 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 From 461f77d6574c1957f10a269dc1416a3f82276bcb Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Wed, 14 Aug 2024 01:37:53 +0200 Subject: [PATCH 32/59] simplify implementation --- .../InvenTree/InvenTree/serializers.py | 21 ++++--------------- 1 file changed, 4 insertions(+), 17 deletions(-) diff --git a/src/backend/InvenTree/InvenTree/serializers.py b/src/backend/InvenTree/InvenTree/serializers.py index 99f253499689..4b8553cc7803 100644 --- a/src/backend/InvenTree/InvenTree/serializers.py +++ b/src/backend/InvenTree/InvenTree/serializers.py @@ -219,16 +219,6 @@ class InvenTreeCustomStatusSerializerMixin: _custom_fields_follower: Optional[list] = None _is_gathering = False - def create(self, validated_data): - """Ensure the custom field is set when the leader is set initially.""" - self.gather_custom_fields() - for field in self._custom_fields_follower: - if field not in self.initial_data: - validated_data[field] = int( - self.initial_data[field.replace('_custom_key', '')] - ) - return super().create(validated_data) - def update(self, instance, validated_data): """Ensure the custom field is updated if the leader was changed.""" self.gather_custom_fields() @@ -256,13 +246,10 @@ 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(): - try: - if data[field] is None: - data[field] = data[ - field.replace('_custom_key', '') - ] # Use "normal" status field instead - except KeyError: - pass + if data[field] is None: + data[field] = data[ + field.replace('_custom_key', '') + ] # Use "normal" status field instead return data def gather_custom_fields(self): From 30c8979730550025cd6135c69e1a6961eec7836c Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Wed, 14 Aug 2024 01:39:24 +0200 Subject: [PATCH 33/59] update tests --- src/backend/InvenTree/stock/test_api.py | 32 ++++++++++++++++++------- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/src/backend/InvenTree/stock/test_api.py b/src/backend/InvenTree/stock/test_api.py index 6302acaac496..af73fa1b73d3 100644 --- a/src/backend/InvenTree/stock/test_api.py +++ b/src/backend/InvenTree/stock/test_api.py @@ -969,24 +969,40 @@ def test_custom_status(self): ) 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 - response2 = self.patch( - reverse('api-stock-detail', kwargs={'pk': response.data['pk']}), + response = self.patch( + reverse('api-stock-detail', kwargs={'pk': pk}), {'status_custom_key': self.status2.key}, expected_code=200, ) - self.assertEqual(response2.data['status'], self.status2.logical_key) - self.assertEqual(response2.data['status_custom_key'], self.status2.key) + 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 - response3 = self.patch( - reverse('api-stock-detail', kwargs={'pk': response.data['pk']}), + response = self.patch( + reverse('api-stock-detail', kwargs={'pk': pk}), {'status': self.status.logical_key}, expected_code=200, ) - self.assertEqual(response3.data['status'], self.status.logical_key) - self.assertEqual(response3.data['status_custom_key'], self.status.logical_key) + 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.""" From cbf86769e3c1ea6ba84bbc9fb7cbb8ad46ed3875 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Wed, 14 Aug 2024 01:45:05 +0200 Subject: [PATCH 34/59] fix name --- src/backend/InvenTree/InvenTree/generic_fields.py | 4 ++-- src/backend/InvenTree/InvenTree/serializers.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/backend/InvenTree/InvenTree/generic_fields.py b/src/backend/InvenTree/InvenTree/generic_fields.py index 8a4801da5cb5..261d2fd9dd75 100644 --- a/src/backend/InvenTree/InvenTree/generic_fields.py +++ b/src/backend/InvenTree/InvenTree/generic_fields.py @@ -29,7 +29,7 @@ def __init__(self, choices: Iterable, **kwargs): super().__init__(choices, **kwargs) self.choice_mdl = choice_mdl self.choice_field = choice_field - self.is_is_custom = is_custom + 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.""" @@ -40,7 +40,7 @@ def to_internal_value(self, data): try: logical = get_logical_value(data, self.choice_mdl._meta.model_name) - if self.is_is_custom: + if self.is_custom: return logical.key return logical.logical_key except InvenTreeCustomUserStateModel.DoesNotExist: diff --git a/src/backend/InvenTree/InvenTree/serializers.py b/src/backend/InvenTree/InvenTree/serializers.py index 4b8553cc7803..e876496cc5c3 100644 --- a/src/backend/InvenTree/InvenTree/serializers.py +++ b/src/backend/InvenTree/InvenTree/serializers.py @@ -264,7 +264,7 @@ def gather_custom_fields(self): self._is_gathering = True # Gather fields self._custom_fields = { - k: v.is_is_custom + k: v.is_custom for k, v in self.fields.items() if isinstance(v, CustomChoiceField) } From 93113dbcec15a57a9f5579af175b5ba8c998666a Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Wed, 14 Aug 2024 01:46:37 +0200 Subject: [PATCH 35/59] rename test --- src/backend/InvenTree/generic/states/tests.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/backend/InvenTree/generic/states/tests.py b/src/backend/InvenTree/generic/states/tests.py index 5b77bcfd3873..29a9eda5d1be 100644 --- a/src/backend/InvenTree/generic/states/tests.py +++ b/src/backend/InvenTree/generic/states/tests.py @@ -135,7 +135,7 @@ class TTTT(StatusCode): str(e.exception), "Invalid color value 'invalid' for status 'Pending'" ) - def test_tag_function(self): + def test_tag_status_label(self): """Test that the status code tag functions.""" from .tags import status_label From 7d86ad70d406db0d7a81588b9d0a3354a65660b4 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Wed, 14 Aug 2024 02:07:39 +0200 Subject: [PATCH 36/59] simplify tags and test fully --- src/backend/InvenTree/generic/states/custom.py | 4 ++-- src/backend/InvenTree/generic/states/tags.py | 17 +++++------------ src/backend/InvenTree/generic/states/tests.py | 17 ++++++++++++++++- 3 files changed, 23 insertions(+), 15 deletions(-) diff --git a/src/backend/InvenTree/generic/states/custom.py b/src/backend/InvenTree/generic/states/custom.py index ca105c6d3e76..c730e1257de4 100644 --- a/src/backend/InvenTree/generic/states/custom.py +++ b/src/backend/InvenTree/generic/states/custom.py @@ -6,9 +6,9 @@ from .states import StatusCode -def get_custom_status_labels(): +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()} + return {cls.tag(): cls for cls in get_custom_classes(include_custom)} def get_custom_classes(include_custom: bool = True): diff --git a/src/backend/InvenTree/generic/states/tags.py b/src/backend/InvenTree/generic/states/tags.py index cc235cd970b3..f93a6b8a9654 100644 --- a/src/backend/InvenTree/generic/states/tags.py +++ b/src/backend/InvenTree/generic/states/tags.py @@ -4,16 +4,13 @@ from generic.templatetags.generic import register -from .custom import get_custom_classes, get_custom_status_labels +from .custom import get_custom_status_labels @register.simple_tag -def status_label(typ: str, key: int, keys=None, *args, **kwargs): +def status_label(typ: str, key: int, include_custom: bool = False, *args, **kwargs): """Render a status label.""" - if keys is None: - keys = {cls.tag(): cls for cls in get_custom_classes(include_custom=False)} - state = keys.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}'") @@ -22,9 +19,5 @@ def status_label(typ: str, key: int, keys=None, *args, **kwargs): @register.simple_tag def display_status_label(typ: str, key: int, fallback: int, *args, **kwargs): """Render a status label.""" - states = get_custom_status_labels() - if key: - render_key = int(key) - else: - render_key = fallback - return status_label(typ, render_key, *args, keys=states, **kwargs) + 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 29a9eda5d1be..c1bdaead4694 100644 --- a/src/backend/InvenTree/generic/states/tests.py +++ b/src/backend/InvenTree/generic/states/tests.py @@ -136,7 +136,7 @@ class TTTT(StatusCode): ) def test_tag_status_label(self): - """Test that the status code tag functions.""" + """Test that the status_label tag.""" from .tags import status_label self.assertEqual( @@ -152,6 +152,21 @@ def test_tag_status_label(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() From fb20790236ac7e379a86c4dc60c0738c1d2518ee Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Wed, 14 Aug 2024 02:27:12 +0200 Subject: [PATCH 37/59] extend test to machine status --- src/backend/InvenTree/generic/states/tests.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/backend/InvenTree/generic/states/tests.py b/src/backend/InvenTree/generic/states/tests.py index c1bdaead4694..a77e7ca625b0 100644 --- a/src/backend/InvenTree/generic/states/tests.py +++ b/src/backend/InvenTree/generic/states/tests.py @@ -248,6 +248,14 @@ def test_all_states(self): 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, From c6361be262af63155c9ed429a6e4864315c3629a Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Wed, 14 Aug 2024 02:29:22 +0200 Subject: [PATCH 38/59] move logic for response formatting over --- src/backend/InvenTree/generic/states/api.py | 7 ++---- .../InvenTree/generic/states/custom.py | 24 +++++++++++++++---- 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/src/backend/InvenTree/generic/states/api.py b/src/backend/InvenTree/generic/states/api.py index 87ca58daa3d6..27f6790b75f1 100644 --- a/src/backend/InvenTree/generic/states/api.py +++ b/src/backend/InvenTree/generic/states/api.py @@ -7,7 +7,7 @@ from rest_framework.generics import GenericAPIView from rest_framework.response import Response -from generic.states.custom import get_custom_classes +from generic.states.custom import get_status_api_response from InvenTree.serializers import EmptySerializer from .states import StatusCode @@ -74,8 +74,5 @@ class AllStatusViews(StatusView): def get(self, request, *args, **kwargs): """Perform a GET request to learn information about status codes.""" - data = { - k.__name__: {'class': k.__name__, 'values': k.dict()} - for k in get_custom_classes() - } + data = get_status_api_response() return Response(data) diff --git a/src/backend/InvenTree/generic/states/custom.py b/src/backend/InvenTree/generic/states/custom.py index c730e1257de4..ba7f0168108f 100644 --- a/src/backend/InvenTree/generic/states/custom.py +++ b/src/backend/InvenTree/generic/states/custom.py @@ -11,10 +11,26 @@ def get_custom_status_labels(include_custom: bool = True): return {cls.tag(): cls for cls in get_custom_classes(include_custom)} -def get_custom_classes(include_custom: bool = True): +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) + } + + +def get_custom_classes(include_custom: bool = True, base_class=StatusCode): """Return a dict of status classes (custom and class defined).""" if not include_custom: - return inheritors(StatusCode) + return inheritors(base_class) # Gather DB settings custom_db_states = {} @@ -27,7 +43,7 @@ def get_custom_classes(include_custom: bool = True): custom_db_mdls_keys = custom_db_mdls.keys() states = {} - for cls in inheritors(StatusCode): + for cls in inheritors(base_class): tag = cls.tag() states[tag] = cls if custom_db_mdls and tag in custom_db_mdls_keys: @@ -46,5 +62,5 @@ def get_custom_classes(include_custom: bool = True): ] # Re-assemble the enum - states[tag] = StatusCode(f'{tag.capitalize()}Status', data) + states[tag] = base_class(f'{tag.capitalize()}Status', data) return states.values() From fb2d62d8d4a9506b9aba78a3f458136314b1de5e Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Wed, 14 Aug 2024 02:32:09 +0200 Subject: [PATCH 39/59] extend api response with machine status --- src/backend/InvenTree/generic/states/api.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/backend/InvenTree/generic/states/api.py b/src/backend/InvenTree/generic/states/api.py index 27f6790b75f1..5d50c6999e40 100644 --- a/src/backend/InvenTree/generic/states/api.py +++ b/src/backend/InvenTree/generic/states/api.py @@ -9,6 +9,7 @@ from generic.states.custom import get_status_api_response from InvenTree.serializers import EmptySerializer +from machine.machine_type import MachineStatus from .states import StatusCode @@ -75,4 +76,6 @@ class AllStatusViews(StatusView): def get(self, request, *args, **kwargs): """Perform a GET request to learn information about status codes.""" data = get_status_api_response() + # Extend with MachineStatus classes + data.update(get_status_api_response(MachineStatus, prefix=['MachineStatus'])) return Response(data) From 5affeed2d7c73459b950feb115545aaa1b1e1677 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Wed, 14 Aug 2024 03:00:38 +0200 Subject: [PATCH 40/59] ensure only direct subclasses are discovered --- src/backend/InvenTree/InvenTree/helpers.py | 14 +++++++++++--- src/backend/InvenTree/generic/states/custom.py | 12 ++++++++---- 2 files changed, 19 insertions(+), 7 deletions(-) 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/generic/states/custom.py b/src/backend/InvenTree/generic/states/custom.py index ba7f0168108f..eaf4dc5975b0 100644 --- a/src/backend/InvenTree/generic/states/custom.py +++ b/src/backend/InvenTree/generic/states/custom.py @@ -23,14 +23,18 @@ def get_status_api_response(base_class=StatusCode, prefix=None): 'class': k.__name__, 'values': k.dict(), } - for k in get_custom_classes(base_class) + for k in get_custom_classes(base_class=base_class, subclass=False) } -def get_custom_classes(include_custom: bool = True, base_class=StatusCode): +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 inheritors(base_class) + return discovered_classes # Gather DB settings custom_db_states = {} @@ -43,7 +47,7 @@ def get_custom_classes(include_custom: bool = True, base_class=StatusCode): custom_db_mdls_keys = custom_db_mdls.keys() states = {} - for cls in inheritors(base_class): + for cls in discovered_classes: tag = cls.tag() states[tag] = cls if custom_db_mdls and tag in custom_db_mdls_keys: From eb57800f5fcd77d9278d656582774a243d2a6d1d Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Wed, 14 Aug 2024 03:00:55 +0200 Subject: [PATCH 41/59] test for length of total respone too --- src/backend/InvenTree/generic/states/tests.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/backend/InvenTree/generic/states/tests.py b/src/backend/InvenTree/generic/states/tests.py index a77e7ca625b0..f3d3093d1f7f 100644 --- a/src/backend/InvenTree/generic/states/tests.py +++ b/src/backend/InvenTree/generic/states/tests.py @@ -229,6 +229,7 @@ class ApiTests(InvenTreeAPITestCase): 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), 11) # Test the BuildStatus model build_status = response.data['BuildStatus'] @@ -267,6 +268,8 @@ def test_all_states(self): reference_status='StockStatus', ) response = self.get(reverse('api-status-all')) + self.assertEqual(len(response.data), 11) + stock_status_cstm = response.data['StockStatus'] self.assertEqual(stock_status_cstm['class'], 'StockStatus') self.assertEqual(len(stock_status_cstm['values']), 9) From 8782fc7a2c7bce1556dde4df8df27037f7352184 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Wed, 14 Aug 2024 08:22:40 +0200 Subject: [PATCH 42/59] use new fields on PUI too --- src/frontend/src/components/render/Build.tsx | 4 ++-- src/frontend/src/components/render/Order.tsx | 6 +++--- src/frontend/src/forms/BuildForms.tsx | 2 +- src/frontend/src/forms/StockForms.tsx | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) 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/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/forms/BuildForms.tsx b/src/frontend/src/forms/BuildForms.tsx index 3e8d86adb14e..2adafe7debd6 100644 --- a/src/frontend/src/forms/BuildForms.tsx +++ b/src/frontend/src/forms/BuildForms.tsx @@ -295,7 +295,7 @@ export function useCompleteBuildOutputsForm({ }; }) }, - status: {}, + status_custom_key: {}, location: { filters: { structural: false 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: {} }; From 4e94d96b24b816aa90a251042d8658229fcb525a Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Wed, 14 Aug 2024 08:30:14 +0200 Subject: [PATCH 43/59] fix test assertion with plugins enabled --- src/backend/InvenTree/generic/states/tests.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/backend/InvenTree/generic/states/tests.py b/src/backend/InvenTree/generic/states/tests.py index f3d3093d1f7f..dbba2d14b23e 100644 --- a/src/backend/InvenTree/generic/states/tests.py +++ b/src/backend/InvenTree/generic/states/tests.py @@ -229,7 +229,7 @@ class ApiTests(InvenTreeAPITestCase): 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), 11) + self.assertEqual(len(response.data), 12) # Test the BuildStatus model build_status = response.data['BuildStatus'] @@ -268,7 +268,7 @@ def test_all_states(self): reference_status='StockStatus', ) response = self.get(reverse('api-status-all')) - self.assertEqual(len(response.data), 11) + self.assertEqual(len(response.data), 12) stock_status_cstm = response.data['StockStatus'] self.assertEqual(stock_status_cstm['class'], 'StockStatus') From 1852e7ecaac77ee2ea2b2a0ad14765b81f0f3826 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Thu, 15 Aug 2024 19:53:42 +0200 Subject: [PATCH 44/59] also observe rendering in filters --- src/frontend/src/tables/ColumnRenderers.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/frontend/src/tables/ColumnRenderers.tsx b/src/frontend/src/tables/ColumnRenderers.tsx index 49ba57e678ed..a9e4ad568b8b 100644 --- a/src/frontend/src/tables/ColumnRenderers.tsx +++ b/src/frontend/src/tables/ColumnRenderers.tsx @@ -178,7 +178,7 @@ export function StatusColumn({ sortable: sortable ?? true, title: title, hidden: hidden, - render: TableStatusRenderer(model, accessor ?? 'status') + render: TableStatusRenderer(model, accessor ?? 'status_custom_key') }; } From d462cadbd62f19051d5989a0f353fe6c07d0a599 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Fri, 16 Aug 2024 00:26:56 +0200 Subject: [PATCH 45/59] Add managment endpoints and APIs --- src/backend/InvenTree/common/api.py | 13 +- src/backend/InvenTree/common/models.py | 7 + src/backend/InvenTree/common/serializers.py | 23 +++ src/backend/InvenTree/generic/states/api.py | 52 +++++++ src/frontend/src/enums/ApiEndpoints.tsx | 1 + src/frontend/src/forms/CommonForms.tsx | 12 ++ .../Index/Settings/AdminCenter/Index.tsx | 10 ++ .../src/tables/settings/CustomStateTable.tsx | 137 ++++++++++++++++++ 8 files changed, 244 insertions(+), 11 deletions(-) create mode 100644 src/frontend/src/tables/settings/CustomStateTable.tsx diff --git a/src/backend/InvenTree/common/api.py b/src/backend/InvenTree/common/api.py index 870104923103..4d5a5ec3403a 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 @@ -965,16 +965,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/models.py b/src/backend/InvenTree/common/models.py index aaf4bcffb845..50751d4d88db 100644 --- a/src/backend/InvenTree/common/models.py +++ b/src/backend/InvenTree/common/models.py @@ -54,6 +54,7 @@ import report.helpers import users.models from generic.states import ColorEnum, StatusCode +from generic.states.custom import get_custom_classes from InvenTree.helpers import inheritors from InvenTree.sanitizer import sanitize_svg from plugin import registry @@ -3348,6 +3349,11 @@ def state_color_mappings(): 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)] + + class InvenTreeCustomUserStateModel(models.Model): """Custom model to extends any registered state with extra custom, user defined states.""" @@ -3387,6 +3393,7 @@ class InvenTreeCustomUserStateModel(models.Model): ) reference_status = models.CharField( max_length=250, + choices=state_reference_mappings(), verbose_name=_('Reference Status Set'), help_text=_('Status set that is extended with this custom state'), ) diff --git a/src/backend/InvenTree/common/serializers.py b/src/backend/InvenTree/common/serializers.py index 4c1f6a30dda0..eca9f8b242ca 100644 --- a/src/backend/InvenTree/common/serializers.py +++ b/src/backend/InvenTree/common/serializers.py @@ -308,6 +308,29 @@ 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') + + class FlagSerializer(serializers.Serializer): """Serializer for feature flags.""" diff --git a/src/backend/InvenTree/generic/states/api.py b/src/backend/InvenTree/generic/states/api.py index 5d50c6999e40..45838f2b3fe7 100644 --- a/src/backend/InvenTree/generic/states/api.py +++ b/src/backend/InvenTree/generic/states/api.py @@ -2,12 +2,20 @@ 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 @@ -79,3 +87,47 @@ def get(self, request, *args, **kwargs): # Extend with MachineStatus classes data.update(get_status_api_response(MachineStatus, prefix=['MachineStatus'])) return Response(data) + + +# Custom states +class CustomStateList(DataExportViewMixin, ListCreateAPI): + """List view for all custom states.""" + + queryset = common.models.InvenTreeCustomUserStateModel.objects.all() + serializer_class = common.serializers.CustomStateSerializer + permission_classes = [permissions.IsAuthenticated, IsStaffOrReadOnly] + filter_backends = SEARCH_ORDER_FILTER + ordering_fields = ['key'] + search_fields = ['key', 'name', 'label', 'reference_status'] + + +class CustomStateDetail(RetrieveUpdateDestroyAPI): + """Detail view for a particular custom states.""" + + queryset = common.models.InvenTreeCustomUserStateModel.objects.all() + serializer_class = common.serializers.CustomStateSerializer + permission_classes = [permissions.IsAuthenticated, IsStaffOrReadOnly] + + +urlpattern = [ + path( + '', + include([ + path( + f'/', + include([path('', StatusView.as_view(), name='api-status')]), + ), + path('', AllStatusViews.as_view(), name='api-status-all'), + ]), + ), + # Custom state + path( + 'custom-state/', + include([ + path( + '/', CustomStateDetail.as_view(), name='api-custom-state-detail' + ), + path('', CustomStateList.as_view(), name='api-custom-state-list'), + ]), + ), +] diff --git a/src/frontend/src/enums/ApiEndpoints.tsx b/src/frontend/src/enums/ApiEndpoints.tsx index 3cb3b1b1f089..88d012443505 100644 --- a/src/frontend/src/enums/ApiEndpoints.tsx +++ b/src/frontend/src/enums/ApiEndpoints.tsx @@ -42,6 +42,7 @@ export enum ApiEndpoints { generate_barcode = 'barcode/generate/', news = 'news/', global_status = 'generic/status/', + custom_state_list = 'generic/custom-state/', version = 'version/', license = 'license/', sso_providers = 'auth/providers/', diff --git a/src/frontend/src/forms/CommonForms.tsx b/src/frontend/src/forms/CommonForms.tsx index 58d267ec8bd8..1279323f4376 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/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/tables/settings/CustomStateTable.tsx b/src/frontend/src/tables/settings/CustomStateTable.tsx new file mode 100644 index 000000000000..10141f53afae --- /dev/null +++ b/src/frontend/src/tables/settings/CustomStateTable.tsx @@ -0,0 +1,137 @@ +import { t } from '@lingui/macro'; +import { useCallback, useMemo, useState } from 'react'; + +import { AddItemButton } from '../../components/buttons/AddItemButton'; +import { ApiEndpoints } from '../../enums/ApiEndpoints'; +import { UserRoles } from '../../enums/Roles'; +import { customStateFields } from '../../forms/CommonForms'; +import { + useCreateApiFormModal, + useDeleteApiFormModal, + useEditApiFormModal +} from '../../hooks/UseForm'; +import { useTable } from '../../hooks/UseTable'; +import { apiUrl } from '../../states/ApiState'; +import { useUserState } from '../../states/UserState'; +import { TableColumn } from '../Column'; +import { InvenTreeTable } from '../InvenTreeTable'; +import { RowAction, RowDeleteAction, RowEditAction } from '../RowActions'; + +/** + * Table for displaying list of custom states + */ +export default function CustomStateTable() { + const table = useTable('customstates'); + + const user = useUserState(); + + const columns: TableColumn[] = useMemo(() => { + 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} + + + ); +} From f7a44967a7a6d05bdc152f57c54bbb86d35d2caa Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Fri, 16 Aug 2024 00:35:16 +0200 Subject: [PATCH 46/59] Add contenttypes to PUI renderes --- src/backend/InvenTree/InvenTree/metadata.py | 2 +- src/backend/InvenTree/common/api.py | 3 +++ src/frontend/src/components/render/Generic.tsx | 6 ++++++ src/frontend/src/components/render/Instance.tsx | 9 +++++++-- src/frontend/src/components/render/ModelType.tsx | 5 +++++ src/frontend/src/enums/ApiEndpoints.tsx | 1 + src/frontend/src/enums/ModelType.tsx | 3 ++- 7 files changed, 25 insertions(+), 4 deletions(-) diff --git a/src/backend/InvenTree/InvenTree/metadata.py b/src/backend/InvenTree/InvenTree/metadata.py index 74a66f50599d..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': diff --git a/src/backend/InvenTree/common/api.py b/src/backend/InvenTree/common/api.py index 4d5a5ec3403a..9e48f99e7221 100644 --- a/src/backend/InvenTree/common/api.py +++ b/src/backend/InvenTree/common/api.py @@ -655,6 +655,9 @@ class ContentTypeList(ListAPI): queryset = ContentType.objects.all() serializer_class = common.serializers.ContentTypeSerializer permission_classes = [permissions.IsAuthenticated] + filter_backends = SEARCH_ORDER_FILTER + ordering_fields = ['pk'] + search_fields = ['pk', 'app_label', 'model'] class ContentTypeDetail(RetrieveAPI): 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/enums/ApiEndpoints.tsx b/src/frontend/src/enums/ApiEndpoints.tsx index 88d012443505..d656087af66c 100644 --- a/src/frontend/src/enums/ApiEndpoints.tsx +++ b/src/frontend/src/enums/ApiEndpoints.tsx @@ -48,6 +48,7 @@ export enum ApiEndpoints { 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' } From afad1a7a9518f528372daadc052dbc47282e3b81 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Fri, 16 Aug 2024 00:35:32 +0200 Subject: [PATCH 47/59] use filteres instead --- src/backend/InvenTree/common/models.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/backend/InvenTree/common/models.py b/src/backend/InvenTree/common/models.py index 50751d4d88db..9cebfb4840a8 100644 --- a/src/backend/InvenTree/common/models.py +++ b/src/backend/InvenTree/common/models.py @@ -53,9 +53,8 @@ import plugin.base.barcodes.helper import report.helpers import users.models -from generic.states import ColorEnum, StatusCode +from generic.states import ColorEnum from generic.states.custom import get_custom_classes -from InvenTree.helpers import inheritors from InvenTree.sanitizer import sanitize_svg from plugin import registry @@ -3376,7 +3375,6 @@ class InvenTreeCustomUserStateModel(models.Model): verbose_name=_('Color'), help_text=_('Color that will be displayed in the frontend'), ) - logical_key = models.IntegerField( verbose_name=_('Logical Key'), help_text=_( @@ -3435,9 +3433,12 @@ def clean(self) -> None: }) # Ensure that the key is not in the range of the logical keys of the reference status - ref_set = [ - x for x in inheritors(StatusCode) if x.__name__ == self.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') From f15084050933effe2a8bc1baf820b47ec2d4c199 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Fri, 16 Aug 2024 00:35:53 +0200 Subject: [PATCH 48/59] fix import order --- src/backend/InvenTree/generic/states/custom.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/backend/InvenTree/generic/states/custom.py b/src/backend/InvenTree/generic/states/custom.py index eaf4dc5975b0..12dc23dd70bb 100644 --- a/src/backend/InvenTree/generic/states/custom.py +++ b/src/backend/InvenTree/generic/states/custom.py @@ -1,6 +1,5 @@ """Helper functions for custom status labels.""" -from common.models import InvenTreeCustomUserStateModel from InvenTree.helpers import inheritors from .states import StatusCode @@ -37,6 +36,8 @@ def get_custom_classes( return discovered_classes # Gather DB settings + from common.models import InvenTreeCustomUserStateModel + custom_db_states = {} custom_db_mdls = {} for item in list(InvenTreeCustomUserStateModel.objects.all()): From 65c8642bbe36e4a87c4cdb3bc807f489faaf27f7 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Fri, 16 Aug 2024 00:36:11 +0200 Subject: [PATCH 49/59] fix api route definition --- src/backend/InvenTree/generic/states/api.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/backend/InvenTree/generic/states/api.py b/src/backend/InvenTree/generic/states/api.py index 45838f2b3fe7..49c742533534 100644 --- a/src/backend/InvenTree/generic/states/api.py +++ b/src/backend/InvenTree/generic/states/api.py @@ -110,24 +110,25 @@ class CustomStateDetail(RetrieveUpdateDestroyAPI): urlpattern = [ + # Custom state path( - '', + 'custom/', include([ path( - f'/', - include([path('', StatusView.as_view(), name='api-status')]), + '/', CustomStateDetail.as_view(), name='api-custom-state-detail' ), - path('', AllStatusViews.as_view(), name='api-status-all'), + path('', CustomStateList.as_view(), name='api-custom-state-list'), ]), ), - # Custom state + # Generic status views path( - 'custom-state/', + '', include([ path( - '/', CustomStateDetail.as_view(), name='api-custom-state-detail' + f'/', + include([path('', StatusView.as_view(), name='api-status')]), ), - path('', CustomStateList.as_view(), name='api-custom-state-list'), + path('', AllStatusViews.as_view(), name='api-status-all'), ]), ), ] From 3347c3782ea5816976d1de580f153b0d7e172e54 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Fri, 16 Aug 2024 00:46:45 +0200 Subject: [PATCH 50/59] move status choices to serializer --- src/backend/InvenTree/common/models.py | 1 - src/backend/InvenTree/common/serializers.py | 3 +++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/backend/InvenTree/common/models.py b/src/backend/InvenTree/common/models.py index 9cebfb4840a8..01774c3caf10 100644 --- a/src/backend/InvenTree/common/models.py +++ b/src/backend/InvenTree/common/models.py @@ -3391,7 +3391,6 @@ class InvenTreeCustomUserStateModel(models.Model): ) reference_status = models.CharField( max_length=250, - choices=state_reference_mappings(), verbose_name=_('Reference Status Set'), help_text=_('Status set that is extended with this custom state'), ) diff --git a/src/backend/InvenTree/common/serializers.py b/src/backend/InvenTree/common/serializers.py index eca9f8b242ca..971f74ddebce 100644 --- a/src/backend/InvenTree/common/serializers.py +++ b/src/backend/InvenTree/common/serializers.py @@ -329,6 +329,9 @@ class Meta: ] model_name = serializers.CharField(read_only=True, source='model.name') + reference_status = serializers.ChoiceField( + choices=common_models.state_reference_mappings() + ) class FlagSerializer(serializers.Serializer): From 80451d41df689b4264ae4c2f90aa42999650ebd9 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Fri, 16 Aug 2024 01:13:48 +0200 Subject: [PATCH 51/59] fix lookup --- src/backend/InvenTree/common/models.py | 2 +- src/frontend/src/enums/ApiEndpoints.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/backend/InvenTree/common/models.py b/src/backend/InvenTree/common/models.py index 01774c3caf10..7841db55432f 100644 --- a/src/backend/InvenTree/common/models.py +++ b/src/backend/InvenTree/common/models.py @@ -3371,7 +3371,7 @@ class InvenTreeCustomUserStateModel(models.Model): color = models.CharField( max_length=10, choices=state_color_mappings(), - default=ColorEnum.secondary, + default=str(ColorEnum.secondary), verbose_name=_('Color'), help_text=_('Color that will be displayed in the frontend'), ) diff --git a/src/frontend/src/enums/ApiEndpoints.tsx b/src/frontend/src/enums/ApiEndpoints.tsx index d656087af66c..9a692fb9d483 100644 --- a/src/frontend/src/enums/ApiEndpoints.tsx +++ b/src/frontend/src/enums/ApiEndpoints.tsx @@ -42,7 +42,7 @@ export enum ApiEndpoints { generate_barcode = 'barcode/generate/', news = 'news/', global_status = 'generic/status/', - custom_state_list = 'generic/custom-state/', + custom_state_list = 'generic/status/custom/', version = 'version/', license = 'license/', sso_providers = 'auth/providers/', From 486aedb2eb8f6ccdacdce878a13eac1da6ef4927 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Fri, 16 Aug 2024 01:18:40 +0200 Subject: [PATCH 52/59] fix filtering --- src/backend/InvenTree/common/api.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/backend/InvenTree/common/api.py b/src/backend/InvenTree/common/api.py index 9e48f99e7221..fe5e6227625a 100644 --- a/src/backend/InvenTree/common/api.py +++ b/src/backend/InvenTree/common/api.py @@ -656,8 +656,7 @@ class ContentTypeList(ListAPI): serializer_class = common.serializers.ContentTypeSerializer permission_classes = [permissions.IsAuthenticated] filter_backends = SEARCH_ORDER_FILTER - ordering_fields = ['pk'] - search_fields = ['pk', 'app_label', 'model'] + search_fields = ['app_label', 'model'] class ContentTypeDetail(RetrieveAPI): From 5de254f0d5f8820d1c81d968b9bf0b55369f30de Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Fri, 16 Aug 2024 01:19:24 +0200 Subject: [PATCH 53/59] remove admin integration --- src/backend/InvenTree/common/admin.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/backend/InvenTree/common/admin.py b/src/backend/InvenTree/common/admin.py index c23c34b3c0a3..a0719f9ab463 100644 --- a/src/backend/InvenTree/common/admin.py +++ b/src/backend/InvenTree/common/admin.py @@ -111,4 +111,3 @@ class NewsFeedEntryAdmin(admin.ModelAdmin): admin.site.register(common.models.WebhookMessage, ImportExportModelAdmin) -admin.site.register(common.models.InvenTreeCustomUserStateModel, admin.ModelAdmin) From 01b15bd0839be313e4889ff2eab6ab4fca7bb058 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Fri, 16 Aug 2024 01:25:03 +0200 Subject: [PATCH 54/59] cleanup migration --- .../0029_inventreecustomuserstatemodel.py | 17 +++++------------ src/backend/InvenTree/common/models.py | 2 +- 2 files changed, 6 insertions(+), 13 deletions(-) diff --git a/src/backend/InvenTree/common/migrations/0029_inventreecustomuserstatemodel.py b/src/backend/InvenTree/common/migrations/0029_inventreecustomuserstatemodel.py index ce899acb36a9..94300f098d37 100644 --- a/src/backend/InvenTree/common/migrations/0029_inventreecustomuserstatemodel.py +++ b/src/backend/InvenTree/common/migrations/0029_inventreecustomuserstatemodel.py @@ -1,8 +1,9 @@ # Generated by Django 4.2.14 on 2024-08-07 22:40 -from django.db import migrations, models import django.db.models.deletion -import generic.states.states +from django.db import migrations, models + +from common.models import state_reference_mappings class Migration(migrations.Migration): @@ -51,16 +52,8 @@ class Migration(migrations.Migration): ( "color", models.CharField( - choices=[ - ("primary", "primary"), - ("secondary", "secondary"), - ("success", "success"), - ("danger", "danger"), - ("warning", "warning"), - ("info", "info"), - ("dark", "dark"), - ], - default=generic.states.states.ColorEnum["secondary"], + choices=state_reference_mappings(), + default="secondary", help_text="Color that will be displayed in the frontend", max_length=10, verbose_name="Color", diff --git a/src/backend/InvenTree/common/models.py b/src/backend/InvenTree/common/models.py index 7841db55432f..24e779331969 100644 --- a/src/backend/InvenTree/common/models.py +++ b/src/backend/InvenTree/common/models.py @@ -3371,7 +3371,7 @@ class InvenTreeCustomUserStateModel(models.Model): color = models.CharField( max_length=10, choices=state_color_mappings(), - default=str(ColorEnum.secondary), + default=ColorEnum.secondary.value, verbose_name=_('Color'), help_text=_('Color that will be displayed in the frontend'), ) From 448cdb87e4f24c758d78a90fb1e835c6ee18ba4d Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Fri, 16 Aug 2024 02:37:22 +0200 Subject: [PATCH 55/59] fix migration change --- .../common/migrations/0029_inventreecustomuserstatemodel.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/backend/InvenTree/common/migrations/0029_inventreecustomuserstatemodel.py b/src/backend/InvenTree/common/migrations/0029_inventreecustomuserstatemodel.py index 94300f098d37..7090b982790b 100644 --- a/src/backend/InvenTree/common/migrations/0029_inventreecustomuserstatemodel.py +++ b/src/backend/InvenTree/common/migrations/0029_inventreecustomuserstatemodel.py @@ -3,7 +3,7 @@ import django.db.models.deletion from django.db import migrations, models -from common.models import state_reference_mappings +from common.models import state_color_mappings class Migration(migrations.Migration): @@ -52,7 +52,7 @@ class Migration(migrations.Migration): ( "color", models.CharField( - choices=state_reference_mappings(), + choices=state_color_mappings(), default="secondary", help_text="Color that will be displayed in the frontend", max_length=10, From 261f8fc9336d74a3176b5e2d0a9af3ef3df0f6f0 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Fri, 16 Aug 2024 23:10:48 +0200 Subject: [PATCH 56/59] cleanup code location --- .../InvenTree/InvenTree/generic_fields.py | 126 ---------- .../InvenTree/InvenTree/serializers.py | 117 +-------- src/backend/InvenTree/build/models.py | 4 +- src/backend/InvenTree/build/serializers.py | 50 ++-- src/backend/InvenTree/common/models.py | 12 +- src/backend/InvenTree/common/serializers.py | 3 +- .../InvenTree/generic/states/custom.py | 19 +- .../InvenTree/generic/states/fields.py | 228 ++++++++++++++++++ src/backend/InvenTree/order/models.py | 2 +- src/backend/InvenTree/order/serializers.py | 2 +- src/backend/InvenTree/stock/models.py | 2 +- 11 files changed, 284 insertions(+), 281 deletions(-) delete mode 100644 src/backend/InvenTree/InvenTree/generic_fields.py create mode 100644 src/backend/InvenTree/generic/states/fields.py diff --git a/src/backend/InvenTree/InvenTree/generic_fields.py b/src/backend/InvenTree/InvenTree/generic_fields.py deleted file mode 100644 index 261d2fd9dd75..000000000000 --- a/src/backend/InvenTree/InvenTree/generic_fields.py +++ /dev/null @@ -1,126 +0,0 @@ -"""Custom model/serializer fields for InvenTree models that support custom states.""" - -from typing import Any, Iterable - -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 - - -def get_logical_value(value, model: str): - """Get the state model for the selected value.""" - from common.models import InvenTreeCustomUserStateModel - - return InvenTreeCustomUserStateModel.objects.get(key=value, model__model=model) - - -class CustomChoiceField(serializers.ChoiceField): - """Custom Choice Field.""" - - 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: - from common.models import InvenTreeCustomUserStateModel - - try: - logical = get_logical_value(data, self.choice_mdl._meta.model_name) - if self.is_custom: - return logical.key - return logical.logical_key - except InvenTreeCustomUserStateModel.DoesNotExist: - 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 InvenTreeCustomStatusExtraModelField(models.PositiveIntegerField): - """Cusotm field used to detect custom extenteded fields.""" - - -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. - """ - - 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 = InvenTreeCustomStatusExtraModelField( - 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 ExtraCustomChoiceField(CustomChoiceField): - """Custom Choice Field that returns value of status if empty.""" - - def to_representation(self, value): - """Return the value of the status if it is empty.""" - return super().to_representation(value) or value diff --git a/src/backend/InvenTree/InvenTree/serializers.py b/src/backend/InvenTree/InvenTree/serializers.py index e876496cc5c3..406368511559 100644 --- a/src/backend/InvenTree/InvenTree/serializers.py +++ b/src/backend/InvenTree/InvenTree/serializers.py @@ -4,7 +4,6 @@ from collections import OrderedDict from copy import deepcopy from decimal import Decimal -from typing import Optional from django.conf import settings from django.contrib.auth.models import User @@ -18,7 +17,7 @@ from djmoney.utils import MONEY_CLASSES, get_currency_field_name from rest_framework import serializers from rest_framework.exceptions import PermissionDenied, ValidationError -from rest_framework.fields import ChoiceField, empty +from rest_framework.fields import empty from rest_framework.mixins import ListModelMixin from rest_framework.serializers import DecimalField from rest_framework.utils import model_meta @@ -27,13 +26,6 @@ import common.models as common_models from common.currency import currency_code_default, currency_code_mappings from InvenTree.fields import InvenTreeRestURLField, InvenTreeURLField -from InvenTree.generic_fields import ( - CustomChoiceField, - ExtraCustomChoiceField, - InvenTreeCustomStatusExtraModelField, - InvenTreeCustomStatusModelField, - get_logical_value, -) class EmptySerializer(serializers.Serializer): @@ -211,113 +203,6 @@ def to_representation(self, value): return None -class InvenTreeCustomStatusSerializerMixin: - """Mixin to ensure custom status fields are set.""" - - _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, InvenTreeCustomStatusExtraModelField): - 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 - - class InvenTreeModelSerializer(serializers.ModelSerializer): """Inherits the standard Django ModelSerializer class, but also ensures that the underlying model class data are checked on validation.""" diff --git a/src/backend/InvenTree/build/models.py b/src/backend/InvenTree/build/models.py index c714394eb8f0..899145f98d6c 100644 --- a/src/backend/InvenTree/build/models.py +++ b/src/backend/InvenTree/build/models.py @@ -21,7 +21,6 @@ from rest_framework import serializers -import InvenTree.generic_fields from build.status_codes import BuildStatus, BuildStatusGroups from stock.status_codes import StockStatus, StockHistoryCode @@ -44,6 +43,7 @@ import report.mixins import stock.models import users.models +import generic.states logger = logging.getLogger('inventree') @@ -316,7 +316,7 @@ def get_absolute_url(self): help_text=_('Number of stock items which have been completed') ) - status = InvenTree.generic_fields.InvenTreeCustomStatusModelField( + 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 18dfe5bc1b03..c0be6a0fc1c3 100644 --- a/src/backend/InvenTree/build/serializers.py +++ b/src/backend/InvenTree/build/serializers.py @@ -2,39 +2,47 @@ 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, InvenTreeCustomStatusSerializerMixin - -import InvenTree.helpers -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 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 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 diff --git a/src/backend/InvenTree/common/models.py b/src/backend/InvenTree/common/models.py index 24e779331969..ca3b45c9dce2 100644 --- a/src/backend/InvenTree/common/models.py +++ b/src/backend/InvenTree/common/models.py @@ -54,7 +54,7 @@ import report.helpers import users.models from generic.states import ColorEnum -from generic.states.custom import get_custom_classes +from generic.states.custom import get_custom_classes, state_color_mappings from InvenTree.sanitizer import sanitize_svg from plugin import registry @@ -3343,16 +3343,6 @@ def check_permission(self, permission, user): return model_class.check_attachment_permission(permission, user) -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)] - - class InvenTreeCustomUserStateModel(models.Model): """Custom model to extends any registered state with extra custom, user defined states.""" diff --git a/src/backend/InvenTree/common/serializers.py b/src/backend/InvenTree/common/serializers.py index 971f74ddebce..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 @@ -330,7 +331,7 @@ class Meta: model_name = serializers.CharField(read_only=True, source='model.name') reference_status = serializers.ChoiceField( - choices=common_models.state_reference_mappings() + choices=generic.states.custom.state_reference_mappings() ) diff --git a/src/backend/InvenTree/generic/states/custom.py b/src/backend/InvenTree/generic/states/custom.py index 12dc23dd70bb..2539eb550ebe 100644 --- a/src/backend/InvenTree/generic/states/custom.py +++ b/src/backend/InvenTree/generic/states/custom.py @@ -2,7 +2,7 @@ from InvenTree.helpers import inheritors -from .states import StatusCode +from .states import ColorEnum, StatusCode def get_custom_status_labels(include_custom: bool = True): @@ -26,6 +26,23 @@ def get_status_api_response(base_class=StatusCode, prefix=None): } +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 ): diff --git a/src/backend/InvenTree/generic/states/fields.py b/src/backend/InvenTree/generic/states/fields.py new file mode 100644 index 000000000000..6a2aceef7fab --- /dev/null +++ b/src/backend/InvenTree/generic/states/fields.py @@ -0,0 +1,228 @@ +"""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.""" + + 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.""" + + 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. + """ + + 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): + """Cusotm field used to detect custom extenteded fields.""" + + +class InvenTreeCustomStatusSerializerMixin: + """Mixin to ensure custom status fields are set.""" + + _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/order/models.py b/src/backend/InvenTree/order/models.py index 60373413dbb8..88b3325da690 100644 --- a/src/backend/InvenTree/order/models.py +++ b/src/backend/InvenTree/order/models.py @@ -37,13 +37,13 @@ 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, InvenTreeURLField, RoundingDecimalField, ) -from InvenTree.generic_fields import InvenTreeCustomStatusModelField from InvenTree.helpers import decimal2string, pui_url from InvenTree.helpers_model import notify_responsible from order.status_codes import ( diff --git a/src/backend/InvenTree/order/serializers.py b/src/backend/InvenTree/order/serializers.py index 6478ce4bc902..e980fccfb46c 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 ( @@ -43,7 +44,6 @@ ) from InvenTree.serializers import ( InvenTreeCurrencySerializer, - InvenTreeCustomStatusSerializerMixin, InvenTreeDecimalField, InvenTreeModelSerializer, InvenTreeMoneySerializer, diff --git a/src/backend/InvenTree/stock/models.py b/src/backend/InvenTree/stock/models.py index f057f95a2c95..e0482795d524 100644 --- a/src/backend/InvenTree/stock/models.py +++ b/src/backend/InvenTree/stock/models.py @@ -37,8 +37,8 @@ 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 InvenTree.generic_fields import InvenTreeCustomStatusModelField from InvenTree.status_codes import ( SalesOrderStatusGroups, StockHistoryCode, From 8960a3330ea848c0ebac6653edc01a4b6f545729 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Fri, 16 Aug 2024 23:24:03 +0200 Subject: [PATCH 57/59] fix imports --- ...ld_status_custom_key_alter_build_status.py | 8 +++++--- ...urchaseorder_status_custom_key_and_more.py | 19 ++++++++++--------- ...13_stockitem_status_custom_key_and_more.py | 10 ++++++---- src/backend/InvenTree/stock/serializers.py | 3 ++- 4 files changed, 23 insertions(+), 17 deletions(-) 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 index 504ae36159de..9e0776fd09a4 100644 --- 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 @@ -1,9 +1,11 @@ # Generated by Django 4.2.14 on 2024-08-07 22:40 -import InvenTree.generic_fields import django.core.validators from django.db import migrations +import generic.states.fields +import InvenTree.status_codes + class Migration(migrations.Migration): @@ -15,7 +17,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name="build", name="status_custom_key", - field=InvenTree.generic_fields.InvenTreeCustomStatusExtraModelField( + field=generic.states.fields.ExtraInvenTreeCustomStatusModelField( blank=True, default=None, help_text="Additional status information for this item", @@ -26,7 +28,7 @@ class Migration(migrations.Migration): migrations.AlterField( model_name="build", name="status", - field=InvenTree.generic_fields.InvenTreeCustomStatusModelField( + field=generic.states.fields.InvenTreeCustomStatusModelField( choices=InvenTree.status_codes.BuildStatus.items(), default=10, help_text="Build status code", 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 index 1368e3a52b6b..26993943b5e8 100644 --- 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 @@ -1,7 +1,8 @@ # Generated by Django 4.2.14 on 2024-08-07 22:40 -import InvenTree.generic_fields from django.db import migrations + +import generic.states.fields import InvenTree.status_codes @@ -15,7 +16,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name="purchaseorder", name="status_custom_key", - field=InvenTree.generic_fields.InvenTreeCustomStatusExtraModelField( + field=generic.states.fields.ExtraInvenTreeCustomStatusModelField( blank=True, default=None, help_text="Additional status information for this item", @@ -26,7 +27,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name="returnorder", name="status_custom_key", - field=InvenTree.generic_fields.InvenTreeCustomStatusExtraModelField( + field=generic.states.fields.ExtraInvenTreeCustomStatusModelField( blank=True, default=None, help_text="Additional status information for this item", @@ -37,7 +38,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name="returnorderlineitem", name="outcome_custom_key", - field=InvenTree.generic_fields.InvenTreeCustomStatusExtraModelField( + field=generic.states.fields.ExtraInvenTreeCustomStatusModelField( blank=True, default=None, help_text="Additional status information for this item", @@ -48,7 +49,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name="salesorder", name="status_custom_key", - field=InvenTree.generic_fields.InvenTreeCustomStatusExtraModelField( + field=generic.states.fields.ExtraInvenTreeCustomStatusModelField( blank=True, default=None, help_text="Additional status information for this item", @@ -59,7 +60,7 @@ class Migration(migrations.Migration): migrations.AlterField( model_name="purchaseorder", name="status", - field=InvenTree.generic_fields.InvenTreeCustomStatusModelField( + field=generic.states.fields.InvenTreeCustomStatusModelField( choices=InvenTree.status_codes.PurchaseOrderStatus.items(), default=10, help_text="Purchase order status", @@ -69,7 +70,7 @@ class Migration(migrations.Migration): migrations.AlterField( model_name="returnorder", name="status", - field=InvenTree.generic_fields.InvenTreeCustomStatusModelField( + field=generic.states.fields.InvenTreeCustomStatusModelField( choices=InvenTree.status_codes.ReturnOrderStatus.items(), default=10, help_text="Return order status", @@ -79,7 +80,7 @@ class Migration(migrations.Migration): migrations.AlterField( model_name="returnorderlineitem", name="outcome", - field=InvenTree.generic_fields.InvenTreeCustomStatusModelField( + field=generic.states.fields.InvenTreeCustomStatusModelField( choices=InvenTree.status_codes.ReturnOrderLineStatus.items(), default=10, help_text="Outcome for this line item", @@ -89,7 +90,7 @@ class Migration(migrations.Migration): migrations.AlterField( model_name="salesorder", name="status", - field=InvenTree.generic_fields.InvenTreeCustomStatusModelField( + field=generic.states.fields.InvenTreeCustomStatusModelField( choices=InvenTree.status_codes.SalesOrderStatus.items(), default=10, help_text="Sales order status", 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 index 1e32ef29bb39..5b9bc7e8d6eb 100644 --- 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 @@ -1,9 +1,11 @@ # Generated by Django 4.2.14 on 2024-08-07 22:40 -import InvenTree.generic_fields import django.core.validators from django.db import migrations -import stock.status_codes + +import generic.states +import generic.states.fields +import InvenTree.status_codes class Migration(migrations.Migration): @@ -16,7 +18,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name="stockitem", name="status_custom_key", - field=InvenTree.generic_fields.InvenTreeCustomStatusExtraModelField( + field=generic.states.fields.ExtraInvenTreeCustomStatusModelField( blank=True, default=None, help_text="Additional status information for this item", @@ -27,7 +29,7 @@ class Migration(migrations.Migration): migrations.AlterField( model_name="stockitem", name="status", - field=InvenTree.generic_fields.InvenTreeCustomStatusModelField( + 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/serializers.py b/src/backend/InvenTree/stock/serializers.py index 2625db838e8f..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 @@ -327,7 +328,7 @@ def validate_serial(self, value): @register_importer() class StockItemSerializer( DataImportExportSerializerMixin, - InvenTree.serializers.InvenTreeCustomStatusSerializerMixin, + InvenTreeCustomStatusSerializerMixin, InvenTree.serializers.InvenTreeTagModelSerializer, ): """Serializer for a StockItem. From 0dda98bcda33b9ec91113fcca9b959489a85bbba Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Sat, 17 Aug 2024 13:47:43 +0200 Subject: [PATCH 58/59] Add docs for custom states --- docs/docs/concepts/custom_states.md | 15 ++++++++++++ docs/mkdocs.yml | 1 + .../InvenTree/generic/states/fields.py | 23 +++++++++++++++---- 3 files changed, 34 insertions(+), 5 deletions(-) create mode 100644 docs/docs/concepts/custom_states.md 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/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/generic/states/fields.py b/src/backend/InvenTree/generic/states/fields.py index 6a2aceef7fab..99717d4884a9 100644 --- a/src/backend/InvenTree/generic/states/fields.py +++ b/src/backend/InvenTree/generic/states/fields.py @@ -14,7 +14,10 @@ class CustomChoiceField(serializers.ChoiceField): - """Custom Choice Field.""" + """Custom Choice Field. + + This is not intended to be used directly. + """ def __init__(self, choices: Iterable, **kwargs): """Initialize the field.""" @@ -70,7 +73,10 @@ def get_field_info(self, field, field_info): class ExtraCustomChoiceField(CustomChoiceField): - """Custom Choice Field that returns value of status if empty.""" + """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.""" @@ -80,7 +86,8 @@ def to_representation(self, 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. + 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): @@ -118,11 +125,17 @@ def add_field(self, cls, name): class ExtraInvenTreeCustomStatusModelField(models.PositiveIntegerField): - """Cusotm field used to detect custom extenteded fields.""" + """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.""" + """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 From 25cf63875c9d0810a52defeaaf087b092e899f4d Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Wed, 21 Aug 2024 18:33:33 +0200 Subject: [PATCH 59/59] add links to custom status --- docs/docs/order/purchase_order.md | 2 ++ docs/docs/order/return_order.md | 2 ++ docs/docs/order/sales_order.md | 2 ++ docs/docs/stock/status.md | 4 +++- 4 files changed, 9 insertions(+), 1 deletion(-) 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