User defined states (#7862)

* Add custom user defined states

* make tests more reliable

* fix list options

* Adapt version

* do not engage if rebuilding

* remove unneeded attr

* remove unneeded attr

* fix enum imports

* adapt cove target

* Add status_custom_key to all other serializers

* fix serializer method

* simplify branching

* remove unneeded imports

* inherit read_only status from leader field

* Add more tests

* fix tests

* add test for function

* refactor for easier testing

* move test to seperate class

* Add options testing

* extend serializer

* add test for all states and refactor to reuse already build functions

* use custom field in PUI too

* reset diff

* style fix

* fix comparison

* Add test for str

* test color exceptions too

* remove user state from tracking

* Add intro from model fields too

* update docs

* simplify implementation

* update tests

* fix name

* rename test

* simplify tags and test fully

* extend test to machine status

* move logic for response formatting over

* extend api response with machine  status

* ensure only direct subclasses are discovered

* test for length of total respone too

* use new fields on PUI too

* fix test assertion with plugins enabled

* also observe rendering in filters

* Add managment endpoints and APIs

* Add contenttypes to PUI renderes

* use filteres instead

* fix import order

* fix api route definition

* move status choices to serializer

* fix lookup

* fix filtering

* remove admin integration

* cleanup migration

* fix migration change

* cleanup code location

* fix imports

* Add docs for custom states

* add links to custom status
This commit is contained in:
Matthias Mair 2024-08-21 23:33:15 +02:00 committed by GitHub
parent 0c63e509d2
commit d5086b2fb1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
72 changed files with 1553 additions and 187 deletions

View File

@ -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)

View File

@ -47,6 +47,8 @@ If you want to create your own machine type, please also take a look at the alre
```py ```py
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from generic.states import ColorEnum
from plugin.machine import BaseDriver, BaseMachineType, MachineStatus from plugin.machine import BaseDriver, BaseMachineType, MachineStatus
class ABCBaseDriver(BaseDriver): class ABCBaseDriver(BaseDriver):
@ -72,9 +74,9 @@ class ABCMachine(BaseMachineType):
base_driver = ABCBaseDriver base_driver = ABCBaseDriver
class ABCStatus(MachineStatus): class ABCStatus(MachineStatus):
CONNECTED = 100, _('Connected'), 'success' CONNECTED = 100, _('Connected'), ColorEnum.success
STANDBY = 101, _('Standby'), 'success' STANDBY = 101, _('Standby'), ColorEnum.success
PRINTING = 110, _('Printing'), 'primary' PRINTING = 110, _('Printing'), ColorEnum.primary
MACHINE_STATUS = ABCStatus MACHINE_STATUS = ABCStatus
default_machine_status = ABCStatus.DISCONNECTED default_machine_status = ABCStatus.DISCONNECTED

View File

@ -38,6 +38,8 @@ Refer to the source code for the Purchase Order status codes:
show_source: True show_source: True
members: [] members: []
Purchase Order Status supports [custom states](../concepts/custom_states.md).
### Purchase Order Currency ### 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. 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.

View File

@ -61,6 +61,8 @@ Refer to the source code for the Return Order status codes:
show_source: True show_source: True
members: [] members: []
Return Order Status supports [custom states](../concepts/custom_states.md).
## Create a Return Order ## Create a Return Order
From the Return Order index, click on <span class='badge inventree add'><span class='fas fa-plus-circle'></span> New Return Order</span> which opens the "Create Return Order" form. From the Return Order index, click on <span class='badge inventree add'><span class='fas fa-plus-circle'></span> New Return Order</span> which opens the "Create Return Order" form.

View File

@ -39,6 +39,8 @@ Refer to the source code for the Sales Order status codes:
show_source: True show_source: True
members: [] members: []
Sales Order Status supports [custom states](../concepts/custom_states.md).
### Sales Order Currency ### 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. 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.

View File

@ -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: Below is the list of available stock status codes and their meaning:
| Status | Description | Available | | Status | Description | Available |
| ----------- | ----------- | --- | | ----------- | ----------- | --- |
| <span class='badge inventree success'>OK</span> | Stock item is healthy, nothing wrong to report | <span class='badge inventree success'>Yes</span> | | <span class='badge inventree success'>OK</span> | Stock item is healthy, nothing wrong to report | <span class='badge inventree success'>Yes</span> |
| <span class='badge inventree warning'>Attention needed</span> | Stock item hasn't been checked or tested yet | <span class='badge inventree success'>Yes</span> | | <span class='badge inventree warning'>Attention needed</span> | Stock item hasn't been checked or tested yet | <span class='badge inventree success'>Yes</span> |
@ -38,6 +38,8 @@ Refer to the source code for the Stock status codes:
show_source: True show_source: True
members: [] members: []
Stock Status supports [custom states](../concepts/custom_states.md).
### Default Status Code ### Default Status Code
The default status code for any newly created Stock Item is <span class='badge inventree success'>OK</span> The default status code for any newly created Stock Item is <span class='badge inventree success'>OK</span>

View File

@ -77,6 +77,7 @@ nav:
- Core Concepts: - Core Concepts:
- Terminology: concepts/terminology.md - Terminology: concepts/terminology.md
- Physical Units: concepts/units.md - Physical Units: concepts/units.md
- Custom States: concepts/custom_states.md
- Development: - Development:
- Contributing: develop/contributing.md - Contributing: develop/contributing.md
- Devcontainer: develop/devcontainer.md - Devcontainer: develop/devcontainer.md

View File

@ -1,13 +1,17 @@
"""InvenTree API version information.""" """InvenTree API version information."""
# InvenTree API version # InvenTree API version
INVENTREE_API_VERSION = 245 INVENTREE_API_VERSION = 246
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" """Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """ INVENTREE_API_TEXT = """
v246 - 2024-08-21 : https://github.com/inventree/InvenTree/pull/7862
- Adds custom status fields to various serializers
- Adds endpoints to admin custom status fields
v245 - 2024-08-21 : https://github.com/inventree/InvenTree/pull/7520 v245 - 2024-08-21 : https://github.com/inventree/InvenTree/pull/7520
- Documented pagination fields (no functional changes) - Documented pagination fields (no functional changes)

View File

@ -3,9 +3,9 @@
"""Provides extra global data to all templates.""" """Provides extra global data to all templates."""
import InvenTree.email import InvenTree.email
import InvenTree.ready
import InvenTree.status import InvenTree.status
from generic.states import StatusCode from generic.states.custom import get_custom_classes
from InvenTree.helpers import inheritors
from users.models import RuleSet, check_user_role from users.models import RuleSet, check_user_role
@ -53,7 +53,10 @@ def status_codes(request):
return {} return {}
request._inventree_status_codes = True request._inventree_status_codes = True
return {cls.__name__: cls.template_context() for cls in inheritors(StatusCode)} get_custom = InvenTree.ready.isRebuildingData() is False
return {
cls.__name__: cls.template_context() for cls in get_custom_classes(get_custom)
}
def user_roles(request): def user_roles(request):

View File

@ -953,8 +953,15 @@ def get_objectreference(
Inheritors_T = TypeVar('Inheritors_T') Inheritors_T = TypeVar('Inheritors_T')
def inheritors(cls: type[Inheritors_T]) -> set[type[Inheritors_T]]: def inheritors(
"""Return all classes that are subclasses from the supplied cls.""" 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() subcls = set()
work = [cls] work = [cls]
@ -963,7 +970,8 @@ def inheritors(cls: type[Inheritors_T]) -> set[type[Inheritors_T]]:
for child in parent.__subclasses__(): for child in parent.__subclasses__():
if child not in subcls: if child not in subcls:
subcls.add(child) subcls.add(child)
work.append(child) if subclasses:
work.append(child)
return subcls return subcls

View File

@ -363,7 +363,7 @@ class InvenTreeMetadata(SimpleMetadata):
field_info['type'] = 'related field' field_info['type'] = 'related field'
field_info['model'] = model._meta.model_name field_info['model'] = model._meta.model_name
# Special case for 'user' model # Special case for special models
if field_info['model'] == 'user': if field_info['model'] == 'user':
field_info['api_url'] = '/api/user/' field_info['api_url'] = '/api/user/'
elif field_info['model'] == 'contenttype': elif field_info['model'] == 'contenttype':
@ -381,6 +381,14 @@ class InvenTreeMetadata(SimpleMetadata):
if field_info['type'] == 'dependent field': if field_info['type'] == 'dependent field':
field_info['depends_on'] = field.depends_on 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 return field_info

View File

@ -0,0 +1,39 @@
# Generated by Django 4.2.14 on 2024-08-07 22:40
import django.core.validators
from django.db import migrations
import generic.states.fields
import InvenTree.status_codes
class Migration(migrations.Migration):
dependencies = [
("build", "0051_delete_buildorderattachment"),
]
operations = [
migrations.AddField(
model_name="build",
name="status_custom_key",
field=generic.states.fields.ExtraInvenTreeCustomStatusModelField(
blank=True,
default=None,
help_text="Additional status information for this item",
null=True,
verbose_name="Custom status key",
),
),
migrations.AlterField(
model_name="build",
name="status",
field=generic.states.fields.InvenTreeCustomStatusModelField(
choices=InvenTree.status_codes.BuildStatus.items(),
default=10,
help_text="Build status code",
validators=[django.core.validators.MinValueValidator(0)],
verbose_name="Build Status",
),
),
]

View File

@ -43,6 +43,7 @@ import part.models
import report.mixins import report.mixins
import stock.models import stock.models
import users.models import users.models
import generic.states
logger = logging.getLogger('inventree') logger = logging.getLogger('inventree')
@ -315,7 +316,7 @@ class Build(
help_text=_('Number of stock items which have been completed') help_text=_('Number of stock items which have been completed')
) )
status = models.PositiveIntegerField( status = generic.states.fields.InvenTreeCustomStatusModelField(
verbose_name=_('Build Status'), verbose_name=_('Build Status'),
default=BuildStatus.PENDING.value, default=BuildStatus.PENDING.value,
choices=BuildStatus.items(), choices=BuildStatus.items(),

View File

@ -2,45 +2,53 @@
from decimal import Decimal from decimal import Decimal
from django.db import transaction
from django.core.exceptions import ValidationError as DjangoValidationError from django.core.exceptions import ValidationError as DjangoValidationError
from django.utils.translation import gettext_lazy as _ from django.db import models, transaction
from django.db.models import (
from django.db import models BooleanField,
from django.db.models import ExpressionWrapper, F, FloatField Case,
from django.db.models import Case, Sum, When, Value ExpressionWrapper,
from django.db.models import BooleanField, Q F,
FloatField,
Q,
Sum,
Value,
When,
)
from django.db.models.functions import Coalesce from django.db.models.functions import Coalesce
from django.utils.translation import gettext_lazy as _
from rest_framework import serializers from rest_framework import serializers
from rest_framework.serializers import ValidationError from rest_framework.serializers import ValidationError
from InvenTree.serializers import InvenTreeModelSerializer, UserSerializer
import InvenTree.helpers
import InvenTree.tasks
from InvenTree.serializers import InvenTreeDecimalField, NotesFieldMixin
from stock.status_codes import StockStatus
from stock.generators import generate_batch_code
from stock.models import StockItem, StockLocation
from stock.serializers import StockItemSerializerBrief, LocationBriefSerializer
import build.tasks import build.tasks
import common.models import common.models
from common.serializers import ProjectCodeSerializer
from common.settings import get_global_setting
from importer.mixins import DataImportExportSerializerMixin
import company.serializers import company.serializers
import InvenTree.helpers
import InvenTree.tasks
import part.filters import part.filters
import part.serializers as part_serializers 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 users.serializers import OwnerSerializer
from .models import Build, BuildLine, BuildItem from .models import Build, BuildItem, BuildLine
from .status_codes import BuildStatus from .status_codes import BuildStatus
class BuildSerializer(NotesFieldMixin, DataImportExportSerializerMixin, InvenTreeModelSerializer): class BuildSerializer(NotesFieldMixin, DataImportExportSerializerMixin, InvenTreeCustomStatusSerializerMixin, InvenTreeModelSerializer):
"""Serializes a Build object.""" """Serializes a Build object."""
class Meta: class Meta:
@ -69,6 +77,7 @@ class BuildSerializer(NotesFieldMixin, DataImportExportSerializerMixin, InvenTre
'quantity', 'quantity',
'status', 'status',
'status_text', 'status_text',
'status_custom_key',
'target_date', 'target_date',
'take_from', 'take_from',
'notes', 'notes',

View File

@ -2,17 +2,17 @@
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from generic.states import StatusCode from generic.states import ColorEnum, StatusCode
class BuildStatus(StatusCode): class BuildStatus(StatusCode):
"""Build status codes.""" """Build status codes."""
PENDING = 10, _('Pending'), 'secondary' # Build is pending / active PENDING = 10, _('Pending'), ColorEnum.secondary # Build is pending / active
PRODUCTION = 20, _('Production'), 'primary' # Build is in production PRODUCTION = 20, _('Production'), ColorEnum.primary # Build is in production
ON_HOLD = 25, _('On Hold'), 'warning' # Build is on hold ON_HOLD = 25, _('On Hold'), ColorEnum.warning # Build is on hold
CANCELLED = 30, _('Cancelled'), 'danger' # Build was cancelled CANCELLED = 30, _('Cancelled'), ColorEnum.danger # Build was cancelled
COMPLETE = 40, _('Complete'), 'success' # Build is complete COMPLETE = 40, _('Complete'), ColorEnum.success # Build is complete
class BuildStatusGroups: class BuildStatusGroups:

View File

@ -158,7 +158,7 @@ src="{% static 'img/blank_image.png' %}"
<td><span class='fas fa-info'></span></td> <td><span class='fas fa-info'></span></td>
<td>{% trans "Status" %}</td> <td>{% trans "Status" %}</td>
<td> <td>
{% status_label 'build' build.status %} {% display_status_label 'build' build.status_custom_key build.status %}
</td> </td>
</tr> </tr>
{% if build.target_date %} {% if build.target_date %}
@ -225,7 +225,7 @@ src="{% static 'img/blank_image.png' %}"
{% block page_data %} {% block page_data %}
<h3> <h3>
{% status_label 'build' build.status large=True %} {% display_status_label 'build' build.status_custom_key build.status large=True %}
{% if build.is_overdue %} {% if build.is_overdue %}
<span class='badge rounded-pill bg-danger'>{% trans "Overdue" %}</span> <span class='badge rounded-pill bg-danger'>{% trans "Overdue" %}</span>
{% endif %} {% endif %}

View File

@ -60,7 +60,7 @@
<tr> <tr>
<td><span class='fas fa-info'></span></td> <td><span class='fas fa-info'></span></td>
<td>{% trans "Status" %}</td> <td>{% trans "Status" %}</td>
<td>{% status_label 'build' build.status %}</td> <td>{% display_status_label 'build' build.status_custom_key build.status %}</td>
</tr> </tr>
<tr> <tr>
<td><span class='fas fa-check-circle'></span></td> <td><span class='fas fa-check-circle'></span></td>

View File

@ -29,7 +29,7 @@ import common.models
import common.serializers import common.serializers
from common.icons import get_icon_packs from common.icons import get_icon_packs
from common.settings import get_global_setting 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 importer.mixins import DataExportViewMixin
from InvenTree.api import BulkDeleteMixin, MetadataView from InvenTree.api import BulkDeleteMixin, MetadataView
from InvenTree.config import CONFIG_LOOKUPS from InvenTree.config import CONFIG_LOOKUPS
@ -655,6 +655,8 @@ class ContentTypeList(ListAPI):
queryset = ContentType.objects.all() queryset = ContentType.objects.all()
serializer_class = common.serializers.ContentTypeSerializer serializer_class = common.serializers.ContentTypeSerializer
permission_classes = [permissions.IsAuthenticated] permission_classes = [permissions.IsAuthenticated]
filter_backends = SEARCH_ORDER_FILTER
search_fields = ['app_label', 'model']
class ContentTypeDetail(RetrieveAPI): class ContentTypeDetail(RetrieveAPI):
@ -965,16 +967,7 @@ common_api_urls = [
]), ]),
), ),
# Status # Status
path( path('generic/status/', include(generic_states_api_urls)),
'generic/status/',
include([
path(
f'<str:{StatusView.MODEL_REF}>/',
include([path('', StatusView.as_view(), name='api-status')]),
),
path('', AllStatusViews.as_view(), name='api-status-all'),
]),
),
# Contenttype # Contenttype
path( path(
'contenttype/', 'contenttype/',

View File

@ -0,0 +1,97 @@
# Generated by Django 4.2.14 on 2024-08-07 22:40
import django.db.models.deletion
from django.db import migrations, models
from common.models import state_color_mappings
class Migration(migrations.Migration):
dependencies = [
("contenttypes", "0002_remove_content_type_name"),
("common", "0028_colortheme_user_obj"),
]
operations = [
migrations.CreateModel(
name="InvenTreeCustomUserStateModel",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"key",
models.IntegerField(
help_text="Value that will be saved in the models database",
verbose_name="Key",
),
),
(
"name",
models.CharField(
help_text="Name of the state",
max_length=250,
verbose_name="Name",
),
),
(
"label",
models.CharField(
help_text="Label that will be displayed in the frontend",
max_length=250,
verbose_name="label",
),
),
(
"color",
models.CharField(
choices=state_color_mappings(),
default="secondary",
help_text="Color that will be displayed in the frontend",
max_length=10,
verbose_name="Color",
),
),
(
"logical_key",
models.IntegerField(
help_text="State logical key that is equal to this custom state in business logic",
verbose_name="Logical Key",
),
),
(
"reference_status",
models.CharField(
help_text="Status set that is extended with this custom state",
max_length=250,
verbose_name="Reference Status Set",
),
),
(
"model",
models.ForeignKey(
blank=True,
help_text="Model this state is associated with",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="contenttypes.contenttype",
verbose_name="Model",
),
),
],
options={
"verbose_name": "Custom State",
"verbose_name_plural": "Custom States",
"unique_together": {
("model", "reference_status", "key", "logical_key")
},
},
),
]

View File

@ -53,6 +53,8 @@ import order.validators
import plugin.base.barcodes.helper import plugin.base.barcodes.helper
import report.helpers import report.helpers
import users.models import users.models
from generic.states import ColorEnum
from generic.states.custom import get_custom_classes, state_color_mappings
from InvenTree.sanitizer import sanitize_svg from InvenTree.sanitizer import sanitize_svg
from plugin import registry from plugin import registry
@ -3339,3 +3341,109 @@ class Attachment(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel
raise ValidationError(_('Invalid model type specified for attachment')) raise ValidationError(_('Invalid model type specified for attachment'))
return model_class.check_attachment_permission(permission, user) return model_class.check_attachment_permission(permission, user)
class InvenTreeCustomUserStateModel(models.Model):
"""Custom model to extends any registered state with extra custom, user defined states."""
key = models.IntegerField(
verbose_name=_('Key'),
help_text=_('Value that will be saved in the models database'),
)
name = models.CharField(
max_length=250, verbose_name=_('Name'), help_text=_('Name of the state')
)
label = models.CharField(
max_length=250,
verbose_name=_('label'),
help_text=_('Label that will be displayed in the frontend'),
)
color = models.CharField(
max_length=10,
choices=state_color_mappings(),
default=ColorEnum.secondary.value,
verbose_name=_('Color'),
help_text=_('Color that will be displayed in the frontend'),
)
logical_key = models.IntegerField(
verbose_name=_('Logical Key'),
help_text=_(
'State logical key that is equal to this custom state in business logic'
),
)
model = models.ForeignKey(
ContentType,
on_delete=models.SET_NULL,
null=True,
blank=True,
verbose_name=_('Model'),
help_text=_('Model this state is associated with'),
)
reference_status = models.CharField(
max_length=250,
verbose_name=_('Reference Status Set'),
help_text=_('Status set that is extended with this custom state'),
)
class Meta:
"""Metaclass options for this mixin."""
verbose_name = _('Custom State')
verbose_name_plural = _('Custom States')
unique_together = [['model', 'reference_status', 'key', 'logical_key']]
def __str__(self) -> str:
"""Return string representation of the custom state."""
return f'{self.model.name} ({self.reference_status}): {self.name} | {self.key} ({self.logical_key})'
def save(self, *args, **kwargs) -> None:
"""Ensure that the custom state is valid before saving."""
self.clean()
return super().save(*args, **kwargs)
def clean(self) -> None:
"""Validate custom state data."""
if self.model is None:
raise ValidationError({'model': _('Model must be selected')})
if self.key is None:
raise ValidationError({'key': _('Key must be selected')})
if self.logical_key is None:
raise ValidationError({'logical_key': _('Logical key must be selected')})
# Ensure that the key is not the same as the logical key
if self.key == self.logical_key:
raise ValidationError({'key': _('Key must be different from logical key')})
if self.reference_status is None or self.reference_status == '':
raise ValidationError({
'reference_status': _('Reference status must be selected')
})
# Ensure that the key is not in the range of the logical keys of the reference status
ref_set = list(
filter(
lambda x: x.__name__ == self.reference_status,
get_custom_classes(include_custom=False),
)
)
if len(ref_set) == 0:
raise ValidationError({
'reference_status': _('Reference status set not found')
})
ref_set = ref_set[0]
if self.key in ref_set.keys():
raise ValidationError({
'key': _(
'Key must be different from the logical keys of the reference status'
)
})
if self.logical_key not in ref_set.keys():
raise ValidationError({
'logical_key': _(
'Logical key must be in the logical keys of the reference status'
)
})
return super().clean()

View File

@ -14,6 +14,7 @@ from taggit.serializers import TagListSerializerField
import common.models as common_models import common.models as common_models
import common.validators import common.validators
import generic.states.custom
from importer.mixins import DataImportExportSerializerMixin from importer.mixins import DataImportExportSerializerMixin
from importer.registry import register_importer from importer.registry import register_importer
from InvenTree.helpers import get_objectreference from InvenTree.helpers import get_objectreference
@ -308,6 +309,32 @@ class ProjectCodeSerializer(DataImportExportSerializerMixin, InvenTreeModelSeria
responsible_detail = OwnerSerializer(source='responsible', read_only=True) responsible_detail = OwnerSerializer(source='responsible', read_only=True)
@register_importer()
class CustomStateSerializer(DataImportExportSerializerMixin, InvenTreeModelSerializer):
"""Serializer for the custom state model."""
class Meta:
"""Meta options for CustomStateSerializer."""
model = common_models.InvenTreeCustomUserStateModel
fields = [
'pk',
'key',
'name',
'label',
'color',
'logical_key',
'model',
'model_name',
'reference_status',
]
model_name = serializers.CharField(read_only=True, source='model.name')
reference_status = serializers.ChoiceField(
choices=generic.states.custom.state_reference_mappings()
)
class FlagSerializer(serializers.Serializer): class FlagSerializer(serializers.Serializer):
"""Serializer for feature flags.""" """Serializer for feature flags."""

View File

@ -33,6 +33,7 @@ from .models import (
Attachment, Attachment,
ColorTheme, ColorTheme,
CustomUnit, CustomUnit,
InvenTreeCustomUserStateModel,
InvenTreeSetting, InvenTreeSetting,
InvenTreeUserSetting, InvenTreeUserSetting,
NotesImage, NotesImage,
@ -1586,3 +1587,93 @@ class ValidatorsTest(TestCase):
common.validators.validate_icon('ti:package:non-existing-variant') common.validators.validate_icon('ti:package:non-existing-variant')
common.validators.validate_icon('ti:package:outline') common.validators.validate_icon('ti:package:outline')
class CustomStatusTest(TestCase):
"""Unit tests for the custom status model."""
def setUp(self):
"""Setup for all tests."""
self.data = {
'key': 11,
'name': 'OK - advanced',
'label': 'OK - adv.',
'color': 'secondary',
'logical_key': 10,
'model': ContentType.objects.get(model='stockitem'),
'reference_status': 'StockStatus',
}
def test_validation_model(self):
"""Test that model is present."""
data = self.data
data.pop('model')
with self.assertRaises(ValidationError):
InvenTreeCustomUserStateModel.objects.create(**data)
self.assertEqual(InvenTreeCustomUserStateModel.objects.count(), 0)
def test_validation_key(self):
"""Tests Model must have a key."""
data = self.data
data.pop('key')
with self.assertRaises(ValidationError):
InvenTreeCustomUserStateModel.objects.create(**data)
self.assertEqual(InvenTreeCustomUserStateModel.objects.count(), 0)
def test_validation_logicalkey(self):
"""Tests Logical key must be present."""
data = self.data
data.pop('logical_key')
with self.assertRaises(ValidationError):
InvenTreeCustomUserStateModel.objects.create(**data)
self.assertEqual(InvenTreeCustomUserStateModel.objects.count(), 0)
def test_validation_reference(self):
"""Tests Reference status must be present."""
data = self.data
data.pop('reference_status')
with self.assertRaises(ValidationError):
InvenTreeCustomUserStateModel.objects.create(**data)
self.assertEqual(InvenTreeCustomUserStateModel.objects.count(), 0)
def test_validation_logical_unique(self):
"""Tests Logical key must be unique."""
data = self.data
data['logical_key'] = data['key']
with self.assertRaises(ValidationError):
InvenTreeCustomUserStateModel.objects.create(**data)
self.assertEqual(InvenTreeCustomUserStateModel.objects.count(), 0)
def test_validation_reference_exsists(self):
"""Tests Reference status set not found."""
data = self.data
data['reference_status'] = 'abcd'
with self.assertRaises(ValidationError):
InvenTreeCustomUserStateModel.objects.create(**data)
self.assertEqual(InvenTreeCustomUserStateModel.objects.count(), 0)
def test_validation_key_unique(self):
"""Tests Key must be different from the logical keys of the reference."""
data = self.data
data['key'] = 50
with self.assertRaises(ValidationError):
InvenTreeCustomUserStateModel.objects.create(**data)
self.assertEqual(InvenTreeCustomUserStateModel.objects.count(), 0)
def test_validation_logical_key_exsists(self):
"""Tests Logical key must be in the logical keys of the reference status."""
data = self.data
data['logical_key'] = 12
with self.assertRaises(ValidationError):
InvenTreeCustomUserStateModel.objects.create(**data)
self.assertEqual(InvenTreeCustomUserStateModel.objects.count(), 0)
def test_validation(self):
"""Tests Valid run."""
data = self.data
instance = InvenTreeCustomUserStateModel.objects.create(**data)
self.assertEqual(data['key'], instance.key)
self.assertEqual(InvenTreeCustomUserStateModel.objects.count(), 1)
self.assertEqual(
instance.__str__(), 'Stock Item (StockStatus): OK - advanced | 11 (10)'
)

View File

@ -6,7 +6,13 @@ There is a rendered state for each state value. The rendered state is used for d
States can be extended with custom options for each InvenTree instance - those options are stored in the database and need to link back to state values. 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 from .transition import StateTransitionMixin, TransitionMethod, storage
__all__ = ['StatusCode', 'storage', 'TransitionMethod', 'StateTransitionMixin'] __all__ = [
'ColorEnum',
'StatusCode',
'storage',
'TransitionMethod',
'StateTransitionMixin',
]

View File

@ -2,12 +2,22 @@
import inspect import inspect
from django.urls import include, path
from drf_spectacular.utils import OpenApiResponse, extend_schema from drf_spectacular.utils import OpenApiResponse, extend_schema
from rest_framework import permissions, serializers from rest_framework import permissions, serializers
from rest_framework.generics import GenericAPIView from rest_framework.generics import GenericAPIView
from rest_framework.response import Response 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 InvenTree.serializers import EmptySerializer
from machine.machine_type import MachineStatus
from .states import StatusCode from .states import StatusCode
@ -73,18 +83,52 @@ class AllStatusViews(StatusView):
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
"""Perform a GET request to learn information about status codes.""" """Perform a GET request to learn information about status codes."""
data = {} data = get_status_api_response()
# Extend with MachineStatus classes
def discover_status_codes(parent_status_class, prefix=None): data.update(get_status_api_response(MachineStatus, prefix=['MachineStatus']))
"""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)
return Response(data) return Response(data)
# Custom states
class CustomStateList(DataExportViewMixin, ListCreateAPI):
"""List view for all custom states."""
queryset = common.models.InvenTreeCustomUserStateModel.objects.all()
serializer_class = common.serializers.CustomStateSerializer
permission_classes = [permissions.IsAuthenticated, IsStaffOrReadOnly]
filter_backends = SEARCH_ORDER_FILTER
ordering_fields = ['key']
search_fields = ['key', 'name', 'label', 'reference_status']
class CustomStateDetail(RetrieveUpdateDestroyAPI):
"""Detail view for a particular custom states."""
queryset = common.models.InvenTreeCustomUserStateModel.objects.all()
serializer_class = common.serializers.CustomStateSerializer
permission_classes = [permissions.IsAuthenticated, IsStaffOrReadOnly]
urlpattern = [
# Custom state
path(
'custom/',
include([
path(
'<int:pk>/', CustomStateDetail.as_view(), name='api-custom-state-detail'
),
path('', CustomStateList.as_view(), name='api-custom-state-list'),
]),
),
# Generic status views
path(
'',
include([
path(
f'<str:{StatusView.MODEL_REF}>/',
include([path('', StatusView.as_view(), name='api-status')]),
),
path('', AllStatusViews.as_view(), name='api-status-all'),
]),
),
]

View File

@ -0,0 +1,88 @@
"""Helper functions for custom status labels."""
from InvenTree.helpers import inheritors
from .states import ColorEnum, StatusCode
def get_custom_status_labels(include_custom: bool = True):
"""Return a dict of custom status labels."""
return {cls.tag(): cls for cls in get_custom_classes(include_custom)}
def get_status_api_response(base_class=StatusCode, prefix=None):
"""Return a dict of status classes (custom and class defined).
Args:
base_class: The base class to search for subclasses.
prefix: A list of strings to prefix the class names with.
"""
return {
'__'.join([*(prefix or []), k.__name__]): {
'class': k.__name__,
'values': k.dict(),
}
for k in get_custom_classes(base_class=base_class, subclass=False)
}
def state_color_mappings():
"""Return a list of custom user state colors."""
return [(a.name, a.value) for a in ColorEnum]
def state_reference_mappings():
"""Return a list of custom user state references."""
return [(a.__name__, a.__name__) for a in get_custom_classes(include_custom=False)]
def get_logical_value(value, model: str):
"""Return the state model for the selected value."""
from common.models import InvenTreeCustomUserStateModel
return InvenTreeCustomUserStateModel.objects.get(key=value, model__model=model)
def get_custom_classes(
include_custom: bool = True, base_class=StatusCode, subclass=False
):
"""Return a dict of status classes (custom and class defined)."""
discovered_classes = inheritors(base_class, subclass)
if not include_custom:
return discovered_classes
# Gather DB settings
from common.models import InvenTreeCustomUserStateModel
custom_db_states = {}
custom_db_mdls = {}
for item in list(InvenTreeCustomUserStateModel.objects.all()):
if not custom_db_states.get(item.reference_status):
custom_db_states[item.reference_status] = []
custom_db_states[item.reference_status].append(item)
custom_db_mdls[item.model.app_label] = item.reference_status
custom_db_mdls_keys = custom_db_mdls.keys()
states = {}
for cls in discovered_classes:
tag = cls.tag()
states[tag] = cls
if custom_db_mdls and tag in custom_db_mdls_keys:
data = [(str(m.name), (m.value, m.label, m.color)) for m in states[tag]]
data_keys = [i[0] for i in data]
# Extent with non present tags
for entry in custom_db_states[custom_db_mdls[tag]]:
ref_name = str(entry.name.upper().replace(' ', ''))
if ref_name not in data_keys:
data += [
(
str(entry.name.upper().replace(' ', '')),
(entry.key, entry.label, entry.color),
)
]
# Re-assemble the enum
states[tag] = base_class(f'{tag.capitalize()}Status', data)
return states.values()

View File

@ -0,0 +1,241 @@
"""Custom model/serializer fields for InvenTree models that support custom states."""
from typing import Any, Iterable, Optional
from django.core.exceptions import ObjectDoesNotExist
from django.db import models
from django.utils.encoding import force_str
from django.utils.translation import gettext_lazy as _
from rest_framework import serializers
from rest_framework.fields import ChoiceField
from .custom import get_logical_value
class CustomChoiceField(serializers.ChoiceField):
"""Custom Choice Field.
This is not intended to be used directly.
"""
def __init__(self, choices: Iterable, **kwargs):
"""Initialize the field."""
choice_mdl = kwargs.pop('choice_mdl', None)
choice_field = kwargs.pop('choice_field', None)
is_custom = kwargs.pop('is_custom', False)
kwargs.pop('max_value', None)
kwargs.pop('min_value', None)
super().__init__(choices, **kwargs)
self.choice_mdl = choice_mdl
self.choice_field = choice_field
self.is_custom = is_custom
def to_internal_value(self, data):
"""Map the choice (that might be a custom one) back to the logical value."""
try:
return super().to_internal_value(data)
except serializers.ValidationError:
try:
logical = get_logical_value(data, self.choice_mdl._meta.model_name)
if self.is_custom:
return logical.key
return logical.logical_key
except ObjectDoesNotExist:
raise serializers.ValidationError('Invalid choice')
def get_field_info(self, field, field_info):
"""Return the field information for the given item."""
from common.models import InvenTreeCustomUserStateModel
# Static choices
choices = [
{
'value': choice_value,
'display_name': force_str(choice_name, strings_only=True),
}
for choice_value, choice_name in field.choices.items()
]
# Dynamic choices from InvenTreeCustomUserStateModel
objs = InvenTreeCustomUserStateModel.objects.filter(
model__model=field.choice_mdl._meta.model_name
)
dyn_choices = [
{'value': choice.key, 'display_name': choice.label} for choice in objs.all()
]
if dyn_choices:
all_choices = choices + dyn_choices
field_info['choices'] = sorted(all_choices, key=lambda kv: kv['value'])
else:
field_info['choices'] = choices
return field_info
class ExtraCustomChoiceField(CustomChoiceField):
"""Custom Choice Field that returns value of status if empty.
This is not intended to be used directly.
"""
def to_representation(self, value):
"""Return the value of the status if it is empty."""
return super().to_representation(value) or value
class InvenTreeCustomStatusModelField(models.PositiveIntegerField):
"""Custom model field for extendable status codes.
Adds a secondary *_custom_key field to the model which can be used to store additional status information.
Models using this model field must also include the InvenTreeCustomStatusSerializerMixin in all serializers that create or update the value.
"""
def deconstruct(self):
"""Deconstruct the field for migrations."""
name, path, args, kwargs = super().deconstruct()
return name, path, args, kwargs
def contribute_to_class(self, cls, name):
"""Add the _custom_key field to the model."""
cls._meta.supports_custom_status = True
if not hasattr(self, '_custom_key_field'):
self.add_field(cls, name)
super().contribute_to_class(cls, name)
def clean(self, value: Any, model_instance: Any) -> Any:
"""Ensure that the value is not an empty string."""
if value == '':
value = None
return super().clean(value, model_instance)
def add_field(self, cls, name):
"""Adds custom_key_field to the model class to save additional status information."""
custom_key_field = ExtraInvenTreeCustomStatusModelField(
default=None,
verbose_name=_('Custom status key'),
help_text=_('Additional status information for this item'),
blank=True,
null=True,
)
cls.add_to_class(f'{name}_custom_key', custom_key_field)
self._custom_key_field = custom_key_field
class ExtraInvenTreeCustomStatusModelField(models.PositiveIntegerField):
"""Custom field used to detect custom extenteded fields.
This is not intended to be used directly, if you want to support custom states in your model use InvenTreeCustomStatusModelField.
"""
class InvenTreeCustomStatusSerializerMixin:
"""Mixin to ensure custom status fields are set.
This mixin must be used to ensure that custom status fields are set correctly when updating a model.
"""
_custom_fields: Optional[list] = None
_custom_fields_leader: Optional[list] = None
_custom_fields_follower: Optional[list] = None
_is_gathering = False
def update(self, instance, validated_data):
"""Ensure the custom field is updated if the leader was changed."""
self.gather_custom_fields()
for field in self._custom_fields_leader:
if (
field in self.initial_data
and self.instance
and self.initial_data[field]
!= getattr(self.instance, f'{field}_custom_key', None)
):
setattr(self.instance, f'{field}_custom_key', self.initial_data[field])
for field in self._custom_fields_follower:
if (
field in validated_data
and field.replace('_custom_key', '') not in self.initial_data
):
reference = get_logical_value(
validated_data[field],
self.fields[field].choice_mdl._meta.model_name,
)
validated_data[field.replace('_custom_key', '')] = reference.logical_key
return super().update(instance, validated_data)
def to_representation(self, instance):
"""Ensure custom state fields are not served empty."""
data = super().to_representation(instance)
for field in self.gather_custom_fields():
if data[field] is None:
data[field] = data[
field.replace('_custom_key', '')
] # Use "normal" status field instead
return data
def gather_custom_fields(self):
"""Gather all custom fields on the serializer."""
if self._custom_fields_follower:
self._is_gathering = False
return self._custom_fields_follower
if self._is_gathering:
self._custom_fields = {}
else:
self._is_gathering = True
# Gather fields
self._custom_fields = {
k: v.is_custom
for k, v in self.fields.items()
if isinstance(v, CustomChoiceField)
}
# Separate fields for easier/cheaper access
self._custom_fields_follower = [k for k, v in self._custom_fields.items() if v]
self._custom_fields_leader = [
k for k, v in self._custom_fields.items() if not v
]
return self._custom_fields_follower
def build_standard_field(self, field_name, model_field):
"""Use custom field for custom status model.
This is required because of DRF overwriting all fields with choice sets.
"""
field_cls, field_kwargs = super().build_standard_field(field_name, model_field)
if issubclass(field_cls, ChoiceField) and isinstance(
model_field, InvenTreeCustomStatusModelField
):
field_cls = CustomChoiceField
field_kwargs['choice_mdl'] = model_field.model
field_kwargs['choice_field'] = model_field.name
elif isinstance(model_field, ExtraInvenTreeCustomStatusModelField):
field_cls = ExtraCustomChoiceField
field_kwargs['choice_mdl'] = model_field.model
field_kwargs['choice_field'] = model_field.name
field_kwargs['is_custom'] = True
# Inherit choices from leader
self.gather_custom_fields()
if field_name in self._custom_fields:
leader_field_name = field_name.replace('_custom_key', '')
leader_field = self.fields[leader_field_name]
if hasattr(leader_field, 'choices'):
field_kwargs['choices'] = list(leader_field.choices.items())
elif hasattr(model_field.model, leader_field_name):
leader_model_field = getattr(
model_field.model, leader_field_name
).field
if hasattr(leader_model_field, 'choices'):
field_kwargs['choices'] = leader_model_field.choices
if getattr(leader_field, 'read_only', False) is True:
field_kwargs['read_only'] = True
if 'choices' not in field_kwargs:
field_kwargs['choices'] = []
return field_cls, field_kwargs

View File

@ -2,6 +2,7 @@
import enum import enum
import re import re
from enum import Enum
class BaseEnum(enum.IntEnum): class BaseEnum(enum.IntEnum):
@ -65,10 +66,23 @@ class StatusCode(BaseEnum):
# Normal item definition # Normal item definition
if len(args) == 1: if len(args) == 1:
obj.label = args[0] obj.label = args[0]
obj.color = 'secondary' obj.color = ColorEnum.secondary
else: else:
obj.label = args[1] 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 return obj
@ -181,3 +195,15 @@ class StatusCode(BaseEnum):
ret['list'] = cls.list() ret['list'] = cls.list()
return ret return ret
class ColorEnum(Enum):
"""Enum for color values."""
primary = 'primary'
secondary = 'secondary'
success = 'success'
danger = 'danger'
warning = 'warning'
info = 'info'
dark = 'dark'

View File

@ -3,15 +3,21 @@
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from generic.templatetags.generic import register from generic.templatetags.generic import register
from InvenTree.helpers import inheritors
from .states import StatusCode from .custom import get_custom_status_labels
@register.simple_tag @register.simple_tag
def status_label(typ: str, key: int, *args, **kwargs): def status_label(typ: str, key: int, include_custom: bool = False, *args, **kwargs):
"""Render a status label.""" """Render a status label."""
state = {cls.tag(): cls for cls in inheritors(StatusCode)}.get(typ, None) state = get_custom_status_labels(include_custom=include_custom).get(typ, None)
if state: if state:
return mark_safe(state.render(key, large=kwargs.get('large', False))) return mark_safe(state.render(key, large=kwargs.get('large', False)))
raise ValueError(f"Unknown status type '{typ}'") raise ValueError(f"Unknown status type '{typ}'")
@register.simple_tag
def display_status_label(typ: str, key: int, fallback: int, *args, **kwargs):
"""Render a status label."""
render_key = int(key) if key else fallback
return status_label(typ, render_key, *args, include_custom=True, **kwargs)

View File

@ -1,11 +1,15 @@
"""Tests for the generic states module.""" """Tests for the generic states module."""
from django.contrib.contenttypes.models import ContentType
from django.test.client import RequestFactory from django.test.client import RequestFactory
from django.urls import reverse
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from rest_framework.test import force_authenticate from rest_framework.test import force_authenticate
from InvenTree.unit_test import InvenTreeTestCase from common.models import InvenTreeCustomUserStateModel
from generic.states import ColorEnum
from InvenTree.unit_test import InvenTreeAPITestCase, InvenTreeTestCase
from .api import StatusView from .api import StatusView
from .states import StatusCode from .states import StatusCode
@ -14,9 +18,9 @@ from .states import StatusCode
class GeneralStatus(StatusCode): class GeneralStatus(StatusCode):
"""Defines a set of status codes for tests.""" """Defines a set of status codes for tests."""
PENDING = 10, _('Pending'), 'secondary' PENDING = 10, _('Pending'), ColorEnum.secondary
PLACED = 20, _('Placed'), 'primary' PLACED = 20, _('Placed'), 'primary'
COMPLETE = 30, _('Complete'), 'success' COMPLETE = 30, _('Complete'), ColorEnum.success
ABC = None # This should be ignored ABC = None # This should be ignored
_DEF = None # This should be ignored _DEF = None # This should be ignored
jkl = None # This should be ignored jkl = None # This should be ignored
@ -120,8 +124,19 @@ class GeneralStateTest(InvenTreeTestCase):
# label # label
self.assertEqual(GeneralStatus.label(10), 'Pending') self.assertEqual(GeneralStatus.label(10), 'Pending')
def test_tag_function(self): def test_color(self):
"""Test that the status code tag functions.""" """Test that the color enum validation works."""
with self.assertRaises(ValueError) as e:
class TTTT(StatusCode):
PENDING = 10, _('Pending'), 'invalid'
self.assertEqual(
str(e.exception), "Invalid color value 'invalid' for status 'Pending'"
)
def test_tag_status_label(self):
"""Test that the status_label tag."""
from .tags import status_label from .tags import status_label
self.assertEqual( self.assertEqual(
@ -137,6 +152,21 @@ class GeneralStateTest(InvenTreeTestCase):
# Test non-existent key # Test non-existent key
self.assertEqual(status_label('general', 100), '100') 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),
"<span class='badge rounded-pill bg-secondary'>Pending</span>",
)
# Fallback
self.assertEqual(display_status_label('general', None, 11), '11')
self.assertEqual(
display_status_label('general', None, 10),
"<span class='badge rounded-pill bg-secondary'>Pending</span>",
)
def test_api(self): def test_api(self):
"""Test StatusView API view.""" """Test StatusView API view."""
view = StatusView.as_view() view = StatusView.as_view()
@ -191,3 +221,59 @@ class GeneralStateTest(InvenTreeTestCase):
self.assertEqual( self.assertEqual(
str(e.exception), '`status_class` not a valid StatusCode class' str(e.exception), '`status_class` not a valid StatusCode class'
) )
class ApiTests(InvenTreeAPITestCase):
"""Test the API for the generic states module."""
def test_all_states(self):
"""Test the API endpoint for listing all status models."""
response = self.get(reverse('api-status-all'))
self.assertEqual(len(response.data), 12)
# Test the BuildStatus model
build_status = response.data['BuildStatus']
self.assertEqual(build_status['class'], 'BuildStatus')
self.assertEqual(len(build_status['values']), 5)
pending = build_status['values']['PENDING']
self.assertEqual(pending['key'], 10)
self.assertEqual(pending['name'], 'PENDING')
self.assertEqual(pending['label'], 'Pending')
# Test the StockStatus model (static)
stock_status = response.data['StockStatus']
self.assertEqual(stock_status['class'], 'StockStatus')
self.assertEqual(len(stock_status['values']), 8)
in_stock = stock_status['values']['OK']
self.assertEqual(in_stock['key'], 10)
self.assertEqual(in_stock['name'], 'OK')
self.assertEqual(in_stock['label'], 'OK')
# MachineStatus model
machine_status = response.data['MachineStatus__LabelPrinterStatus']
self.assertEqual(machine_status['class'], 'LabelPrinterStatus')
self.assertEqual(len(machine_status['values']), 6)
connected = machine_status['values']['CONNECTED']
self.assertEqual(connected['key'], 100)
self.assertEqual(connected['name'], 'CONNECTED')
# Add custom status
InvenTreeCustomUserStateModel.objects.create(
key=11,
name='OK - advanced',
label='OK - adv.',
color='secondary',
logical_key=10,
model=ContentType.objects.get(model='stockitem'),
reference_status='StockStatus',
)
response = self.get(reverse('api-status-all'))
self.assertEqual(len(response.data), 12)
stock_status_cstm = response.data['StockStatus']
self.assertEqual(stock_status_cstm['class'], 'StockStatus')
self.assertEqual(len(stock_status_cstm['values']), 9)
ok_advanced = stock_status_cstm['values']['OK']
self.assertEqual(ok_advanced['key'], 10)
self.assertEqual(ok_advanced['name'], 'OK')
self.assertEqual(ok_advanced['label'], 'OK')

View File

@ -2,18 +2,26 @@
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from generic.states import StatusCode from generic.states import ColorEnum, StatusCode
class DataImportStatusCode(StatusCode): class DataImportStatusCode(StatusCode):
"""Defines a set of status codes for a DataImportSession.""" """Defines a set of status codes for a DataImportSession."""
INITIAL = 0, _('Initializing'), 'secondary' # Import session has been created INITIAL = (
MAPPING = 10, _('Mapping Columns'), 'primary' # Import fields are being mapped 0,
IMPORTING = 20, _('Importing Data'), 'primary' # Data is being imported _('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 = ( PROCESSING = (
30, 30,
_('Processing Data'), _('Processing Data'),
'primary', ColorEnum.primary,
) # Data is being processed by the user ) # Data is being processed by the user
COMPLETE = 40, _('Complete'), 'success' # Import has been completed COMPLETE = 40, _('Complete'), ColorEnum.success # Import has been completed

View File

@ -12,6 +12,7 @@ from PIL.Image import Image
from rest_framework import serializers from rest_framework import serializers
from rest_framework.request import Request from rest_framework.request import Request
from generic.states import ColorEnum
from machine.machine_type import BaseDriver, BaseMachineType, MachineStatus from machine.machine_type import BaseDriver, BaseMachineType, MachineStatus
from plugin import registry as plg_registry from plugin import registry as plg_registry
from plugin.base.label.mixins import LabelPrintingMixin from plugin.base.label.mixins import LabelPrintingMixin
@ -228,12 +229,12 @@ class LabelPrinterStatus(MachineStatus):
DISCONNECTED: The driver cannot establish a connection to the printer DISCONNECTED: The driver cannot establish a connection to the printer
""" """
CONNECTED = 100, _('Connected'), 'success' CONNECTED = 100, _('Connected'), ColorEnum.success
UNKNOWN = 101, _('Unknown'), 'secondary' UNKNOWN = 101, _('Unknown'), ColorEnum.secondary
PRINTING = 110, _('Printing'), 'primary' PRINTING = 110, _('Printing'), ColorEnum.primary
NO_MEDIA = 301, _('No media'), 'warning' NO_MEDIA = 301, _('No media'), ColorEnum.warning
PAPER_JAM = 302, _('Paper jam'), 'warning' PAPER_JAM = 302, _('Paper jam'), ColorEnum.warning
DISCONNECTED = 400, _('Disconnected'), 'danger' DISCONNECTED = 400, _('Disconnected'), ColorEnum.danger
class LabelPrinterMachine(BaseMachineType): class LabelPrinterMachine(BaseMachineType):

View File

@ -0,0 +1,100 @@
# Generated by Django 4.2.14 on 2024-08-07 22:40
from django.db import migrations
import generic.states.fields
import InvenTree.status_codes
class Migration(migrations.Migration):
dependencies = [
("order", "0100_remove_returnorderattachment_order_and_more"),
]
operations = [
migrations.AddField(
model_name="purchaseorder",
name="status_custom_key",
field=generic.states.fields.ExtraInvenTreeCustomStatusModelField(
blank=True,
default=None,
help_text="Additional status information for this item",
null=True,
verbose_name="Custom status key",
),
),
migrations.AddField(
model_name="returnorder",
name="status_custom_key",
field=generic.states.fields.ExtraInvenTreeCustomStatusModelField(
blank=True,
default=None,
help_text="Additional status information for this item",
null=True,
verbose_name="Custom status key",
),
),
migrations.AddField(
model_name="returnorderlineitem",
name="outcome_custom_key",
field=generic.states.fields.ExtraInvenTreeCustomStatusModelField(
blank=True,
default=None,
help_text="Additional status information for this item",
null=True,
verbose_name="Custom status key",
),
),
migrations.AddField(
model_name="salesorder",
name="status_custom_key",
field=generic.states.fields.ExtraInvenTreeCustomStatusModelField(
blank=True,
default=None,
help_text="Additional status information for this item",
null=True,
verbose_name="Custom status key",
),
),
migrations.AlterField(
model_name="purchaseorder",
name="status",
field=generic.states.fields.InvenTreeCustomStatusModelField(
choices=InvenTree.status_codes.PurchaseOrderStatus.items(),
default=10,
help_text="Purchase order status",
verbose_name="Status",
),
),
migrations.AlterField(
model_name="returnorder",
name="status",
field=generic.states.fields.InvenTreeCustomStatusModelField(
choices=InvenTree.status_codes.ReturnOrderStatus.items(),
default=10,
help_text="Return order status",
verbose_name="Status",
),
),
migrations.AlterField(
model_name="returnorderlineitem",
name="outcome",
field=generic.states.fields.InvenTreeCustomStatusModelField(
choices=InvenTree.status_codes.ReturnOrderLineStatus.items(),
default=10,
help_text="Outcome for this line item",
verbose_name="Outcome",
),
),
migrations.AlterField(
model_name="salesorder",
name="status",
field=generic.states.fields.InvenTreeCustomStatusModelField(
choices=InvenTree.status_codes.SalesOrderStatus.items(),
default=10,
help_text="Sales order status",
verbose_name="Status",
),
),
]

View File

@ -37,6 +37,7 @@ from common.notifications import InvenTreeNotificationBodies
from common.settings import get_global_setting from common.settings import get_global_setting
from company.models import Address, Company, Contact, SupplierPart from company.models import Address, Company, Contact, SupplierPart
from generic.states import StateTransitionMixin from generic.states import StateTransitionMixin
from generic.states.fields import InvenTreeCustomStatusModelField
from InvenTree.exceptions import log_error from InvenTree.exceptions import log_error
from InvenTree.fields import ( from InvenTree.fields import (
InvenTreeModelMoneyField, InvenTreeModelMoneyField,
@ -470,7 +471,7 @@ class PurchaseOrder(TotalPriceMixin, Order):
validators=[order.validators.validate_purchase_order_reference], validators=[order.validators.validate_purchase_order_reference],
) )
status = models.PositiveIntegerField( status = InvenTreeCustomStatusModelField(
default=PurchaseOrderStatus.PENDING.value, default=PurchaseOrderStatus.PENDING.value,
choices=PurchaseOrderStatus.items(), choices=PurchaseOrderStatus.items(),
verbose_name=_('Status'), verbose_name=_('Status'),
@ -996,7 +997,7 @@ class SalesOrder(TotalPriceMixin, Order):
"""Accessor helper for Order base.""" """Accessor helper for Order base."""
return self.customer return self.customer
status = models.PositiveIntegerField( status = InvenTreeCustomStatusModelField(
default=SalesOrderStatus.PENDING.value, default=SalesOrderStatus.PENDING.value,
choices=SalesOrderStatus.items(), choices=SalesOrderStatus.items(),
verbose_name=_('Status'), verbose_name=_('Status'),
@ -2153,7 +2154,7 @@ class ReturnOrder(TotalPriceMixin, Order):
"""Accessor helper for Order base class.""" """Accessor helper for Order base class."""
return self.customer return self.customer
status = models.PositiveIntegerField( status = InvenTreeCustomStatusModelField(
default=ReturnOrderStatus.PENDING.value, default=ReturnOrderStatus.PENDING.value,
choices=ReturnOrderStatus.items(), choices=ReturnOrderStatus.items(),
verbose_name=_('Status'), verbose_name=_('Status'),
@ -2404,7 +2405,7 @@ class ReturnOrderLineItem(OrderLineItem):
"""Return True if this item has been received.""" """Return True if this item has been received."""
return self.received_date is not None return self.received_date is not None
outcome = models.PositiveIntegerField( outcome = InvenTreeCustomStatusModelField(
default=ReturnOrderLineStatus.PENDING.value, default=ReturnOrderLineStatus.PENDING.value,
choices=ReturnOrderLineStatus.items(), choices=ReturnOrderLineStatus.items(),
verbose_name=_('Outcome'), verbose_name=_('Outcome'),

View File

@ -32,6 +32,7 @@ from company.serializers import (
ContactSerializer, ContactSerializer,
SupplierPartSerializer, SupplierPartSerializer,
) )
from generic.states.fields import InvenTreeCustomStatusSerializerMixin
from importer.mixins import DataImportExportSerializerMixin from importer.mixins import DataImportExportSerializerMixin
from importer.registry import register_importer from importer.registry import register_importer
from InvenTree.helpers import ( from InvenTree.helpers import (
@ -161,6 +162,7 @@ class AbstractOrderSerializer(DataImportExportSerializerMixin, serializers.Seria
'address_detail', 'address_detail',
'status', 'status',
'status_text', 'status_text',
'status_custom_key',
'notes', 'notes',
'barcode_hash', 'barcode_hash',
'overdue', 'overdue',
@ -216,7 +218,11 @@ class AbstractExtraLineMeta:
@register_importer() @register_importer()
class PurchaseOrderSerializer( class PurchaseOrderSerializer(
NotesFieldMixin, TotalPriceMixin, AbstractOrderSerializer, InvenTreeModelSerializer NotesFieldMixin,
TotalPriceMixin,
InvenTreeCustomStatusSerializerMixin,
AbstractOrderSerializer,
InvenTreeModelSerializer,
): ):
"""Serializer for a PurchaseOrder object.""" """Serializer for a PurchaseOrder object."""
@ -859,7 +865,11 @@ class PurchaseOrderReceiveSerializer(serializers.Serializer):
@register_importer() @register_importer()
class SalesOrderSerializer( class SalesOrderSerializer(
NotesFieldMixin, TotalPriceMixin, AbstractOrderSerializer, InvenTreeModelSerializer NotesFieldMixin,
TotalPriceMixin,
InvenTreeCustomStatusSerializerMixin,
AbstractOrderSerializer,
InvenTreeModelSerializer,
): ):
"""Serializer for the SalesOrder model class.""" """Serializer for the SalesOrder model class."""
@ -1642,7 +1652,11 @@ class SalesOrderExtraLineSerializer(
@register_importer() @register_importer()
class ReturnOrderSerializer( class ReturnOrderSerializer(
NotesFieldMixin, AbstractOrderSerializer, TotalPriceMixin, InvenTreeModelSerializer NotesFieldMixin,
InvenTreeCustomStatusSerializerMixin,
AbstractOrderSerializer,
TotalPriceMixin,
InvenTreeModelSerializer,
): ):
"""Serializer for the ReturnOrder model class.""" """Serializer for the ReturnOrder model class."""

View File

@ -2,20 +2,20 @@
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from generic.states import StatusCode from generic.states import ColorEnum, StatusCode
class PurchaseOrderStatus(StatusCode): class PurchaseOrderStatus(StatusCode):
"""Defines a set of status codes for a PurchaseOrder.""" """Defines a set of status codes for a PurchaseOrder."""
# Order status codes # Order status codes
PENDING = 10, _('Pending'), 'secondary' # Order is pending (not yet placed) PENDING = 10, _('Pending'), ColorEnum.secondary # Order is pending (not yet placed)
PLACED = 20, _('Placed'), 'primary' # Order has been placed with supplier PLACED = 20, _('Placed'), ColorEnum.primary # Order has been placed with supplier
ON_HOLD = 25, _('On Hold'), 'warning' # Order is on hold ON_HOLD = 25, _('On Hold'), ColorEnum.warning # Order is on hold
COMPLETE = 30, _('Complete'), 'success' # Order has been completed COMPLETE = 30, _('Complete'), ColorEnum.success # Order has been completed
CANCELLED = 40, _('Cancelled'), 'danger' # Order was cancelled CANCELLED = 40, _('Cancelled'), ColorEnum.danger # Order was cancelled
LOST = 50, _('Lost'), 'warning' # Order was lost LOST = 50, _('Lost'), ColorEnum.warning # Order was lost
RETURNED = 60, _('Returned'), 'warning' # Order was returned RETURNED = 60, _('Returned'), ColorEnum.warning # Order was returned
class PurchaseOrderStatusGroups: class PurchaseOrderStatusGroups:
@ -39,18 +39,18 @@ class PurchaseOrderStatusGroups:
class SalesOrderStatus(StatusCode): class SalesOrderStatus(StatusCode):
"""Defines a set of status codes for a SalesOrder.""" """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 = ( IN_PROGRESS = (
15, 15,
_('In Progress'), _('In Progress'),
'primary', ColorEnum.primary,
) # Order has been issued, and is in progress ) # Order has been issued, and is in progress
SHIPPED = 20, _('Shipped'), 'success' # Order has been shipped to customer SHIPPED = 20, _('Shipped'), ColorEnum.success # Order has been shipped to customer
ON_HOLD = 25, _('On Hold'), 'warning' # Order is on hold ON_HOLD = 25, _('On Hold'), ColorEnum.warning # Order is on hold
COMPLETE = 30, _('Complete'), 'success' # Order is complete COMPLETE = 30, _('Complete'), ColorEnum.success # Order is complete
CANCELLED = 40, _('Cancelled'), 'danger' # Order has been cancelled CANCELLED = 40, _('Cancelled'), ColorEnum.danger # Order has been cancelled
LOST = 50, _('Lost'), 'warning' # Order was lost LOST = 50, _('Lost'), ColorEnum.warning # Order was lost
RETURNED = 60, _('Returned'), 'warning' # Order was returned RETURNED = 60, _('Returned'), ColorEnum.warning # Order was returned
class SalesOrderStatusGroups: class SalesOrderStatusGroups:
@ -71,15 +71,15 @@ class ReturnOrderStatus(StatusCode):
"""Defines a set of status codes for a ReturnOrder.""" """Defines a set of status codes for a ReturnOrder."""
# Order is pending, waiting for receipt of items # 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 # 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' COMPLETE = 30, _('Complete'), ColorEnum.success
CANCELLED = 40, _('Cancelled'), 'danger' CANCELLED = 40, _('Cancelled'), ColorEnum.danger
class ReturnOrderStatusGroups: class ReturnOrderStatusGroups:
@ -95,19 +95,19 @@ class ReturnOrderStatusGroups:
class ReturnOrderLineStatus(StatusCode): class ReturnOrderLineStatus(StatusCode):
"""Defines a set of status codes for a ReturnOrderLineItem.""" """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 # 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 # 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) # 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) # Item is to be refunded (cannot be repaired)
REFUND = 50, _('Refund'), 'info' REFUND = 50, _('Refund'), ColorEnum.info
# Item is rejected # Item is rejected
REJECT = 60, _('Reject'), 'danger' REJECT = 60, _('Reject'), ColorEnum.danger

View File

@ -122,7 +122,7 @@ src="{% static 'img/blank_image.png' %}"
<td><span class='fas fa-info'></span></td> <td><span class='fas fa-info'></span></td>
<td>{% trans "Order Status" %}</td> <td>{% trans "Order Status" %}</td>
<td> <td>
{% status_label 'purchase_order' order.status %} {% display_status_label 'purchase_order' order.status_custom_key order.status %}
{% if order.is_overdue %} {% if order.is_overdue %}
<span class='badge rounded-pill bg-danger'>{% trans "Overdue" %}</span> <span class='badge rounded-pill bg-danger'>{% trans "Overdue" %}</span>
{% endif %} {% endif %}

View File

@ -115,7 +115,7 @@ src="{% static 'img/blank_image.png' %}"
<td><span class='fas fa-info'></span></td> <td><span class='fas fa-info'></span></td>
<td>{% trans "Order Status" %}</td> <td>{% trans "Order Status" %}</td>
<td> <td>
{% status_label 'return_order' order.status %} {% display_status_label 'return_order' order.status_custom_key order.status %}
{% if order.is_overdue %} {% if order.is_overdue %}
<span class='badge rounded-pill bg-danger'>{% trans "Overdue" %}</span> <span class='badge rounded-pill bg-danger'>{% trans "Overdue" %}</span>
{% endif %} {% endif %}

View File

@ -124,7 +124,7 @@ src="{% static 'img/blank_image.png' %}"
<td><span class='fas fa-info'></span></td> <td><span class='fas fa-info'></span></td>
<td>{% trans "Order Status" %}</td> <td>{% trans "Order Status" %}</td>
<td> <td>
{% status_label 'sales_order' order.status %} {% display_status_label 'sales_order' order.status_custom_key order.status %}
{% if order.is_overdue %} {% if order.is_overdue %}
<span class='badge rounded-pill bg-danger'>{% trans "Overdue" %}</span> <span class='badge rounded-pill bg-danger'>{% trans "Overdue" %}</span>
{% endif %} {% endif %}

View File

@ -125,6 +125,7 @@ class LabelMixinTests(PrintTestMixins, InvenTreeAPITestCase):
self.assertGreater(len(plugins), 0) self.assertGreater(len(plugins), 0)
plugin = registry.get_plugin('samplelabelprinter') plugin = registry.get_plugin('samplelabelprinter')
self.assertIsNotNone(plugin)
config = plugin.plugin_config() config = plugin.plugin_config()
# Ensure that the plugin is not active # Ensure that the plugin is not active

View File

@ -491,7 +491,10 @@ class PrintTestMixins:
def do_activate_plugin(self): def do_activate_plugin(self):
"""Activate the 'samplelabel' plugin.""" """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.active = True
config.save() config.save()

View File

@ -142,6 +142,7 @@ class StockItemResource(InvenTreeResource):
'barcode_hash', 'barcode_hash',
'barcode_data', 'barcode_data',
'owner', 'owner',
'status_custom_key',
] ]
id = Field( id = Field(

View File

@ -0,0 +1,38 @@
# Generated by Django 4.2.14 on 2024-08-07 22:40
import django.core.validators
from django.db import migrations
import generic.states
import generic.states.fields
import InvenTree.status_codes
class Migration(migrations.Migration):
dependencies = [
("stock", "0112_alter_stocklocation_custom_icon_and_more"),
]
operations = [
migrations.AddField(
model_name="stockitem",
name="status_custom_key",
field=generic.states.fields.ExtraInvenTreeCustomStatusModelField(
blank=True,
default=None,
help_text="Additional status information for this item",
null=True,
verbose_name="Custom status key",
),
),
migrations.AlterField(
model_name="stockitem",
name="status",
field=generic.states.fields.InvenTreeCustomStatusModelField(
choices=InvenTree.status_codes.StockStatus.items(),
default=10,
validators=[django.core.validators.MinValueValidator(0)],
),
),
]

View File

@ -37,13 +37,18 @@ from build import models as BuildModels
from common.icons import validate_icon from common.icons import validate_icon
from common.settings import get_global_setting from common.settings import get_global_setting
from company import models as CompanyModels from company import models as CompanyModels
from generic.states.fields import InvenTreeCustomStatusModelField
from InvenTree.fields import InvenTreeModelMoneyField, InvenTreeURLField from InvenTree.fields import InvenTreeModelMoneyField, InvenTreeURLField
from order.status_codes import SalesOrderStatusGroups from InvenTree.status_codes import (
SalesOrderStatusGroups,
StockHistoryCode,
StockStatus,
StockStatusGroups,
)
from part import models as PartModels from part import models as PartModels
from plugin.events import trigger_event from plugin.events import trigger_event
from stock import models as StockModels from stock import models as StockModels
from stock.generators import generate_batch_code from stock.generators import generate_batch_code
from stock.status_codes import StockHistoryCode, StockStatus, StockStatusGroups
from users.models import Owner from users.models import Owner
logger = logging.getLogger('inventree') logger = logging.getLogger('inventree')
@ -940,7 +945,7 @@ class StockItem(
help_text=_('Delete this Stock Item when stock is depleted'), help_text=_('Delete this Stock Item when stock is depleted'),
) )
status = models.PositiveIntegerField( status = InvenTreeCustomStatusModelField(
default=StockStatus.OK.value, default=StockStatus.OK.value,
choices=StockStatus.items(), choices=StockStatus.items(),
validators=[MinValueValidator(0)], validators=[MinValueValidator(0)],

View File

@ -27,6 +27,7 @@ import part.serializers as part_serializers
import stock.filters import stock.filters
import stock.status_codes import stock.status_codes
from common.settings import get_global_setting from common.settings import get_global_setting
from generic.states.fields import InvenTreeCustomStatusSerializerMixin
from importer.mixins import DataImportExportSerializerMixin from importer.mixins import DataImportExportSerializerMixin
from importer.registry import register_importer from importer.registry import register_importer
from InvenTree.serializers import InvenTreeCurrencySerializer, InvenTreeDecimalField from InvenTree.serializers import InvenTreeCurrencySerializer, InvenTreeDecimalField
@ -326,7 +327,9 @@ class StockItemSerializerBrief(
@register_importer() @register_importer()
class StockItemSerializer( class StockItemSerializer(
DataImportExportSerializerMixin, InvenTree.serializers.InvenTreeTagModelSerializer DataImportExportSerializerMixin,
InvenTreeCustomStatusSerializerMixin,
InvenTree.serializers.InvenTreeTagModelSerializer,
): ):
"""Serializer for a StockItem. """Serializer for a StockItem.
@ -373,6 +376,7 @@ class StockItemSerializer(
'serial', 'serial',
'status', 'status',
'status_text', 'status_text',
'status_custom_key',
'stocktake_date', 'stocktake_date',
'supplier_part', 'supplier_part',
'sku', 'sku',

View File

@ -2,24 +2,28 @@
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from generic.states import StatusCode from generic.states import ColorEnum, StatusCode
class StockStatus(StatusCode): class StockStatus(StatusCode):
"""Status codes for Stock.""" """Status codes for Stock."""
OK = 10, _('OK'), 'success' # Item is OK OK = 10, _('OK'), ColorEnum.success # Item is OK
ATTENTION = 50, _('Attention needed'), 'warning' # Item requires attention ATTENTION = 50, _('Attention needed'), ColorEnum.warning # Item requires attention
DAMAGED = 55, _('Damaged'), 'warning' # Item is damaged DAMAGED = 55, _('Damaged'), ColorEnum.warning # Item is damaged
DESTROYED = 60, _('Destroyed'), 'danger' # Item is destroyed DESTROYED = 60, _('Destroyed'), ColorEnum.danger # Item is destroyed
REJECTED = 65, _('Rejected'), 'danger' # Item is rejected REJECTED = 65, _('Rejected'), ColorEnum.danger # Item is rejected
LOST = 70, _('Lost'), 'dark' # Item has been lost LOST = 70, _('Lost'), ColorEnum.dark # Item has been lost
QUARANTINED = ( QUARANTINED = (
75, 75,
_('Quarantined'), _('Quarantined'),
'info', ColorEnum.info,
) # Item has been quarantined and is unavailable ) # 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: class StockStatusGroups:

View File

@ -425,7 +425,7 @@
<tr> <tr>
<td><span class='fas fa-info'></span></td> <td><span class='fas fa-info'></span></td>
<td>{% trans "Status" %}</td> <td>{% trans "Status" %}</td>
<td>{% status_label 'stock' item.status %}</td> <td>{% display_status_label 'stock' item.status_custom_key item.status %}</td>
</tr> </tr>
{% if item.expiry_date %} {% if item.expiry_date %}
<tr> <tr>

View File

@ -7,6 +7,7 @@ from datetime import datetime, timedelta
from enum import IntEnum from enum import IntEnum
import django.http import django.http
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.urls import reverse from django.urls import reverse
@ -17,7 +18,7 @@ from rest_framework import status
import build.models import build.models
import company.models import company.models
import part.models import part.models
from common.models import InvenTreeSetting from common.models import InvenTreeCustomUserStateModel, InvenTreeSetting
from InvenTree.unit_test import InvenTreeAPITestCase from InvenTree.unit_test import InvenTreeAPITestCase
from part.models import Part, PartTestTemplate from part.models import Part, PartTestTemplate
from stock.models import ( from stock.models import (
@ -925,6 +926,100 @@ class StockItemListTest(StockAPITestCase):
) )
class CustomStockItemStatusTest(StockAPITestCase):
"""Tests for custom stock item statuses."""
list_url = reverse('api-stock-list')
def setUp(self):
"""Setup for all tests."""
super().setUp()
self.status = InvenTreeCustomUserStateModel.objects.create(
key=11,
name='OK - advanced',
label='OK - adv.',
color='secondary',
logical_key=10,
model=ContentType.objects.get(model='stockitem'),
reference_status='StockStatus',
)
self.status2 = InvenTreeCustomUserStateModel.objects.create(
key=51,
name='attention 2',
label='attention 2',
color='secondary',
logical_key=50,
model=ContentType.objects.get(model='stockitem'),
reference_status='StockStatus',
)
def test_custom_status(self):
"""Tests interaction with states."""
# Create a stock item with the custom status code via the API
response = self.post(
self.list_url,
{
'name': 'Test Type 1',
'description': 'Test desc 1',
'quantity': 1,
'part': 1,
'status_custom_key': self.status.key,
},
expected_code=201,
)
self.assertEqual(response.data['status'], self.status.logical_key)
self.assertEqual(response.data['status_custom_key'], self.status.key)
pk = response.data['pk']
# Update the stock item with another custom status code via the API
response = self.patch(
reverse('api-stock-detail', kwargs={'pk': pk}),
{'status_custom_key': self.status2.key},
expected_code=200,
)
self.assertEqual(response.data['status'], self.status2.logical_key)
self.assertEqual(response.data['status_custom_key'], self.status2.key)
# Try if status_custom_key is rewrite with status bying set
response = self.patch(
reverse('api-stock-detail', kwargs={'pk': pk}),
{'status': self.status.logical_key},
expected_code=200,
)
self.assertEqual(response.data['status'], self.status.logical_key)
self.assertEqual(response.data['status_custom_key'], self.status.logical_key)
# Create a stock item with a normal status code via the API
response = self.post(
self.list_url,
{
'name': 'Test Type 1',
'description': 'Test desc 1',
'quantity': 1,
'part': 1,
'status_key': self.status.key,
},
expected_code=201,
)
self.assertEqual(response.data['status'], self.status.logical_key)
self.assertEqual(response.data['status_custom_key'], self.status.logical_key)
def test_options(self):
"""Test the StockItem OPTIONS endpoint to contain custom StockStatuses."""
response = self.options(self.list_url)
self.assertEqual(response.status_code, 200)
# Check that the response contains the custom StockStatuses
actions = response.data['actions']['POST']
self.assertIn('status_custom_key', actions)
status_custom_key = actions['status_custom_key']
self.assertEqual(len(status_custom_key['choices']), 10)
status = status_custom_key['choices'][1]
self.assertEqual(status['value'], self.status.key)
self.assertEqual(status['display_name'], self.status.label)
class StockItemTest(StockAPITestCase): class StockItemTest(StockAPITestCase):
"""Series of API tests for the StockItem API.""" """Series of API tests for the StockItem API."""

View File

@ -615,7 +615,7 @@ function completeBuildOutputs(build_id, outputs, options={}) {
method: 'POST', method: 'POST',
preFormContent: html, preFormContent: html,
fields: { fields: {
status: {}, status_custom_key: {},
location: { location: {
filters: { filters: {
structural: false, structural: false,
@ -644,7 +644,7 @@ function completeBuildOutputs(build_id, outputs, options={}) {
// Extract data elements from the form // Extract data elements from the form
var data = { var data = {
outputs: [], outputs: [],
status: getFormFieldValue('status', {}, opts), status_custom_key: getFormFieldValue('status_custom_key', {}, opts),
location: getFormFieldValue('location', {}, opts), location: getFormFieldValue('location', {}, opts),
notes: getFormFieldValue('notes', {}, opts), notes: getFormFieldValue('notes', {}, opts),
accept_incomplete_allocation: getFormFieldValue('accept_incomplete_allocation', {type: 'boolean'}, opts), accept_incomplete_allocation: getFormFieldValue('accept_incomplete_allocation', {type: 'boolean'}, opts),
@ -1153,7 +1153,7 @@ function loadBuildOrderAllocationTable(table, options={}) {
if (row.build_detail) { if (row.build_detail) {
html += `- <small>${row.build_detail.title}</small>`; html += `- <small>${row.build_detail.title}</small>`;
html += buildStatusDisplay(row.build_detail.status, { html += buildStatusDisplay(row.build_detail.status_custom_key, {
classes: 'float-right', classes: 'float-right',
}); });
} }
@ -1556,7 +1556,7 @@ function loadBuildOutputTable(build_info, options={}) {
text += ` <small>({% trans "Batch" %}: ${row.batch})</small>`; text += ` <small>({% trans "Batch" %}: ${row.batch})</small>`;
} }
text += stockStatusDisplay(row.status, {classes: 'float-right'}); text += stockStatusDisplay(row.status_custom_key, {classes: 'float-right'});
return text; return text;
} }
@ -2362,7 +2362,7 @@ function loadBuildTable(table, options) {
} }
}, },
{ {
field: 'status', field: 'status_custom_key',
title: '{% trans "Status" %}', title: '{% trans "Status" %}',
sortable: true, sortable: true,
formatter: function(value) { formatter: function(value) {

View File

@ -1761,7 +1761,7 @@ function loadPartPurchaseOrderTable(table, part_id, options={}) {
var html = renderLink(order.reference, `/order/purchase-order/${order.pk}/`); var html = renderLink(order.reference, `/order/purchase-order/${order.pk}/`);
html += purchaseOrderStatusDisplay( html += purchaseOrderStatusDisplay(
order.status, order.status_custom_key,
{ {
classes: 'float-right', classes: 'float-right',
} }

View File

@ -1788,12 +1788,12 @@ function loadPurchaseOrderTable(table, options) {
} }
}, },
{ {
field: 'status', field: 'status_custom_key',
title: '{% trans "Status" %}', title: '{% trans "Status" %}',
switchable: true, switchable: true,
sortable: true, sortable: true,
formatter: function(value, row) { formatter: function(value, row) {
return purchaseOrderStatusDisplay(row.status); return purchaseOrderStatusDisplay(row.status_custom_key);
} }
}, },
{ {

View File

@ -326,10 +326,10 @@ function loadReturnOrderTable(table, options={}) {
}, },
{ {
sortable: true, sortable: true,
field: 'status', field: 'status_custom_key',
title: '{% trans "Status" %}', title: '{% trans "Status" %}',
formatter: function(value, row) { formatter: function(value, row) {
return returnOrderStatusDisplay(row.status); return returnOrderStatusDisplay(row.status_custom_key);
} }
}, },
{ {

View File

@ -851,10 +851,10 @@ function loadSalesOrderTable(table, options) {
}, },
{ {
sortable: true, sortable: true,
field: 'status', field: 'status_custom_key',
title: '{% trans "Status" %}', title: '{% trans "Status" %}',
formatter: function(value, row) { formatter: function(value, row) {
return salesOrderStatusDisplay(row.status); return salesOrderStatusDisplay(row.status_custom_key);
} }
}, },
{ {

View File

@ -380,7 +380,7 @@ function stockItemFields(options={}) {
batch: { batch: {
icon: 'fa-layer-group', icon: 'fa-layer-group',
}, },
status: {}, status_custom_key: {},
expiry_date: { expiry_date: {
icon: 'fa-calendar-alt', icon: 'fa-calendar-alt',
}, },
@ -698,7 +698,7 @@ function assignStockToCustomer(items, options={}) {
var thumbnail = thumbnailImage(part.thumbnail || part.image); 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 = ''; var quantity = '';
@ -879,7 +879,7 @@ function mergeStockItems(items, options={}) {
quantity = `{% trans "Quantity" %}: ${item.quantity}`; quantity = `{% trans "Quantity" %}: ${item.quantity}`;
} }
quantity += stockStatusDisplay(item.status, {classes: 'float-right'}); quantity += stockStatusDisplay(item.status_custom_key, {classes: 'float-right'});
let buttons = wrapButtons( let buttons = wrapButtons(
makeIconButton( makeIconButton(
@ -1113,7 +1113,7 @@ function adjustStock(action, items, options={}) {
var thumb = thumbnailImage(item.part_detail.thumbnail || item.part_detail.image); 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' classes: 'float-right'
}); });
@ -1922,7 +1922,8 @@ function makeStockActions(table) {
} }
}, },
{ {
label: 'status',
label: 'status_custom_key',
icon: 'fa-info-circle icon-blue', icon: 'fa-info-circle icon-blue',
title: '{% trans "Change stock status" %}', title: '{% trans "Change stock status" %}',
permission: 'stock.change', permission: 'stock.change',
@ -2257,7 +2258,7 @@ function loadStockTable(table, options) {
columns.push(col); columns.push(col);
col = { col = {
field: 'status', field: 'status_custom_key',
title: '{% trans "Status" %}', title: '{% trans "Status" %}',
formatter: function(value) { formatter: function(value) {
return stockStatusDisplay(value); return stockStatusDisplay(value);
@ -3075,11 +3076,11 @@ function loadStockTrackingTable(table, options) {
} }
// Status information // Status information
if (details.status) { if (details.status_custom_key) {
html += `<tr><th>{% trans "Status" %}</td>`; html += `<tr><th>{% trans "Status" %}</td>`;
html += '<td>'; html += '<td>';
html += stockStatusDisplay(details.status); html += stockStatusDisplay(details.status_custom_key);
html += '</td></tr>'; html += '</td></tr>';
} }
@ -3200,7 +3201,7 @@ function loadInstalledInTable(table, options) {
} }
}, },
{ {
field: 'status', field: 'status_custom_key',
title: '{% trans "Status" %}', title: '{% trans "Status" %}',
formatter: function(value) { formatter: function(value) {
return stockStatusDisplay(value); return stockStatusDisplay(value);
@ -3401,7 +3402,7 @@ function setStockStatus(items, options={}) {
method: 'POST', method: 'POST',
preFormContent: html, preFormContent: html,
fields: { fields: {
status: {}, status_custom_key: {},
note: {}, note: {},
}, },
processBeforeUpload: function(data) { processBeforeUpload: function(data) {

View File

@ -345,6 +345,7 @@ class RuleSet(models.Model):
'common_projectcode', 'common_projectcode',
'common_webhookendpoint', 'common_webhookendpoint',
'common_webhookmessage', 'common_webhookmessage',
'common_inventreecustomuserstatemodel',
'users_owner', 'users_owner',
# Third-party tables # Third-party tables
'error_report_error', 'error_report_error',

View File

@ -19,7 +19,7 @@ export function RenderBuildOrder(
primary={instance.reference} primary={instance.reference}
secondary={instance.title} secondary={instance.title}
suffix={StatusRenderer({ suffix={StatusRenderer({
status: instance.status, status: instance.status_custom_key,
type: ModelType.build type: ModelType.build
})} })}
image={instance.part_detail?.thumbnail || instance.part_detail?.image} image={instance.part_detail?.thumbnail || instance.part_detail?.image}
@ -39,7 +39,7 @@ export function RenderBuildLine({
primary={instance.part_detail.full_name} primary={instance.part_detail.full_name}
secondary={instance.quantity} secondary={instance.quantity}
suffix={StatusRenderer({ suffix={StatusRenderer({
status: instance.status, status: instance.status_custom_key,
type: ModelType.build type: ModelType.build
})} })}
image={instance.part_detail.thumbnail || instance.part_detail.image} image={instance.part_detail.thumbnail || instance.part_detail.image}

View File

@ -15,6 +15,12 @@ export function RenderProjectCode({
); );
} }
export function RenderContentType({
instance
}: Readonly<InstanceRenderInterface>): ReactNode {
return instance && <RenderInlineModel primary={instance.app_labeled_name} />;
}
export function RenderImportSession({ export function RenderImportSession({
instance instance
}: { }: {

View File

@ -16,7 +16,11 @@ import {
RenderManufacturerPart, RenderManufacturerPart,
RenderSupplierPart RenderSupplierPart
} from './Company'; } from './Company';
import { RenderImportSession, RenderProjectCode } from './Generic'; import {
RenderContentType,
RenderImportSession,
RenderProjectCode
} from './Generic';
import { ModelInformationDict } from './ModelType'; import { ModelInformationDict } from './ModelType';
import { import {
RenderPurchaseOrder, RenderPurchaseOrder,
@ -87,7 +91,8 @@ const RendererLookup: EnumDictionary<
[ModelType.importsession]: RenderImportSession, [ModelType.importsession]: RenderImportSession,
[ModelType.reporttemplate]: RenderReportTemplate, [ModelType.reporttemplate]: RenderReportTemplate,
[ModelType.labeltemplate]: RenderLabelTemplate, [ModelType.labeltemplate]: RenderLabelTemplate,
[ModelType.pluginconfig]: RenderPlugin [ModelType.pluginconfig]: RenderPlugin,
[ModelType.contenttype]: RenderContentType
}; };
export type RenderInstanceProps = { export type RenderInstanceProps = {

View File

@ -241,6 +241,11 @@ export const ModelInformationDict: ModelDict = {
url_overview: '/pluginconfig', url_overview: '/pluginconfig',
url_detail: '/pluginconfig/:pk/', url_detail: '/pluginconfig/:pk/',
api_endpoint: ApiEndpoints.plugin_list api_endpoint: ApiEndpoints.plugin_list
},
contenttype: {
label: t`Content Type`,
label_multiple: t`Content Types`,
api_endpoint: ApiEndpoints.content_type_list
} }
}; };

View File

@ -21,7 +21,7 @@ export function RenderPurchaseOrder(
primary={instance.reference} primary={instance.reference}
secondary={instance.description} secondary={instance.description}
suffix={StatusRenderer({ suffix={StatusRenderer({
status: instance.status, status: instance.status_custom_key,
type: ModelType.purchaseorder type: ModelType.purchaseorder
})} })}
image={supplier.thumnbnail || supplier.image} image={supplier.thumnbnail || supplier.image}
@ -49,7 +49,7 @@ export function RenderReturnOrder(
primary={instance.reference} primary={instance.reference}
secondary={instance.description} secondary={instance.description}
suffix={StatusRenderer({ suffix={StatusRenderer({
status: instance.status, status: instance.status_custom_key,
type: ModelType.returnorder type: ModelType.returnorder
})} })}
image={customer.thumnbnail || customer.image} image={customer.thumnbnail || customer.image}
@ -94,7 +94,7 @@ export function RenderSalesOrder(
primary={instance.reference} primary={instance.reference}
secondary={instance.description} secondary={instance.description}
suffix={StatusRenderer({ suffix={StatusRenderer({
status: instance.status, status: instance.status_custom_key,
type: ModelType.salesorder type: ModelType.salesorder
})} })}
image={customer.thumnbnail || customer.image} image={customer.thumnbnail || customer.image}

View File

@ -42,11 +42,13 @@ export enum ApiEndpoints {
generate_barcode = 'barcode/generate/', generate_barcode = 'barcode/generate/',
news = 'news/', news = 'news/',
global_status = 'generic/status/', global_status = 'generic/status/',
custom_state_list = 'generic/status/custom/',
version = 'version/', version = 'version/',
license = 'license/', license = 'license/',
sso_providers = 'auth/providers/', sso_providers = 'auth/providers/',
group_list = 'user/group/', group_list = 'user/group/',
owner_list = 'user/owner/', owner_list = 'user/owner/',
content_type_list = 'contenttype/',
icons = 'icons/', icons = 'icons/',
// Data import endpoints // Data import endpoints

View File

@ -31,5 +31,6 @@ export enum ModelType {
group = 'group', group = 'group',
reporttemplate = 'reporttemplate', reporttemplate = 'reporttemplate',
labeltemplate = 'labeltemplate', labeltemplate = 'labeltemplate',
pluginconfig = 'pluginconfig' pluginconfig = 'pluginconfig',
contenttype = 'contenttype'
} }

View File

@ -301,7 +301,7 @@ export function useCompleteBuildOutputsForm({
}; };
}) })
}, },
status: {}, status_custom_key: {},
location: { location: {
filters: { filters: {
structural: false structural: false

View File

@ -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 { export function customUnitsFields(): ApiFormFieldSet {
return { return {
name: {}, name: {},

View File

@ -138,7 +138,7 @@ export function useStockFields({
value: batchCode, value: batchCode,
onValueChange: (value) => setBatchCode(value) onValueChange: (value) => setBatchCode(value)
}, },
status: {}, status_custom_key: {},
expiry_date: { expiry_date: {
// TODO: icon // TODO: icon
}, },
@ -620,7 +620,7 @@ function stockChangeStatusFields(items: any[]): ApiFormFieldSet {
}, },
headers: [t`Part`, t`Location`, t`In Stock`, t`Actions`] headers: [t`Part`, t`Location`, t`In Stock`, t`Actions`]
}, },
status: {}, status_custom_key: {},
note: {} note: {}
}; };

View File

@ -68,6 +68,10 @@ const ProjectCodeTable = Loadable(
lazy(() => import('../../../../tables/settings/ProjectCodeTable')) lazy(() => import('../../../../tables/settings/ProjectCodeTable'))
); );
const CustomStateTable = Loadable(
lazy(() => import('../../../../tables/settings/CustomStateTable'))
);
const CustomUnitsTable = Loadable( const CustomUnitsTable = Loadable(
lazy(() => import('../../../../tables/settings/CustomUnitsTable')) lazy(() => import('../../../../tables/settings/CustomUnitsTable'))
); );
@ -135,6 +139,12 @@ export default function AdminCenter() {
</Stack> </Stack>
) )
}, },
{
name: 'customstates',
label: t`Custom States`,
icon: <IconListDetails />,
content: <CustomStateTable />
},
{ {
name: 'customunits', name: 'customunits',
label: t`Custom Units`, label: t`Custom Units`,

View File

@ -526,7 +526,7 @@ export default function BuildDetail() {
? [] ? []
: [ : [
<StatusRenderer <StatusRenderer
status={build.status} status={build.status_custom_key}
type={ModelType.build} type={ModelType.build}
options={{ size: 'lg' }} options={{ size: 'lg' }}
/> />

View File

@ -459,7 +459,7 @@ export default function PurchaseOrderDetail() {
? [] ? []
: [ : [
<StatusRenderer <StatusRenderer
status={order.status} status={order.status_custom_key}
type={ModelType.purchaseorder} type={ModelType.purchaseorder}
options={{ size: 'lg' }} options={{ size: 'lg' }}
/> />

View File

@ -300,7 +300,7 @@ export default function ReturnOrderDetail() {
? [] ? []
: [ : [
<StatusRenderer <StatusRenderer
status={order.status} status={order.status_custom_key}
type={ModelType.returnorder} type={ModelType.returnorder}
options={{ size: 'lg' }} options={{ size: 'lg' }}
/> />

View File

@ -498,7 +498,7 @@ export default function SalesOrderDetail() {
? [] ? []
: [ : [
<StatusRenderer <StatusRenderer
status={order.status} status={order.status_custom_key}
type={ModelType.salesorder} type={ModelType.salesorder}
options={{ size: 'lg' }} options={{ size: 'lg' }}
key={order.pk} key={order.pk}

View File

@ -601,7 +601,7 @@ export default function StockDetail() {
key="batch" key="batch"
/>, />,
<StatusRenderer <StatusRenderer
status={stockitem.status} status={stockitem.status_custom_key}
type={ModelType.stockitem} type={ModelType.stockitem}
options={{ size: 'lg' }} options={{ size: 'lg' }}
key="status" key="status"

View File

@ -178,7 +178,7 @@ export function StatusColumn({
sortable: sortable ?? true, sortable: sortable ?? true,
title: title, title: title,
hidden: hidden, hidden: hidden,
render: TableStatusRenderer(model, accessor ?? 'status') render: TableStatusRenderer(model, accessor ?? 'status_custom_key')
}; };
} }

View File

@ -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 [
<AddItemButton
onClick={() => newCustomState.open()}
tooltip={t`Add state`}
/>
];
}, []);
return (
<>
{newCustomState.modal}
{editCustomState.modal}
{deleteCustomState.modal}
<InvenTreeTable
url={apiUrl(ApiEndpoints.custom_state_list)}
tableState={table}
columns={columns}
props={{
rowActions: rowActions,
tableActions: tableActions,
enableDownload: true
}}
/>
</>
);
}