Merge branch 'master' into dependabot/pip/src/backend/djangorestframework-3.15.2

This commit is contained in:
Oliver 2024-08-22 09:42:29 +10:00 committed by GitHub
commit a8951d995c
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
from django.utils.translation import gettext_lazy as _
from generic.states import ColorEnum
from plugin.machine import BaseDriver, BaseMachineType, MachineStatus
class ABCBaseDriver(BaseDriver):
@ -72,9 +74,9 @@ class ABCMachine(BaseMachineType):
base_driver = ABCBaseDriver
class ABCStatus(MachineStatus):
CONNECTED = 100, _('Connected'), 'success'
STANDBY = 101, _('Standby'), 'success'
PRINTING = 110, _('Printing'), 'primary'
CONNECTED = 100, _('Connected'), ColorEnum.success
STANDBY = 101, _('Standby'), ColorEnum.success
PRINTING = 110, _('Printing'), ColorEnum.primary
MACHINE_STATUS = ABCStatus
default_machine_status = ABCStatus.DISCONNECTED

View File

@ -38,6 +38,8 @@ Refer to the source code for the Purchase Order status codes:
show_source: True
members: []
Purchase Order Status supports [custom states](../concepts/custom_states.md).
### Purchase Order Currency
The currency code can be specified for an individual purchase order. If not specified, the default currency specified against the [supplier](./company.md#suppliers) will be used.

View File

@ -61,6 +61,8 @@ Refer to the source code for the Return Order status codes:
show_source: True
members: []
Return Order Status supports [custom states](../concepts/custom_states.md).
## Create a Return Order
From the Return Order index, click on <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
members: []
Sales Order Status supports [custom states](../concepts/custom_states.md).
### Sales Order Currency
The currency code can be specified for an individual sales order. If not specified, the default currency specified against the [customer](./company.md#customers) will be used.

View File

@ -38,6 +38,8 @@ Refer to the source code for the Stock status codes:
show_source: True
members: []
Stock Status supports [custom states](../concepts/custom_states.md).
### Default Status Code
The default status code for any newly created Stock Item is <span class='badge inventree success'>OK</span>

View File

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

View File

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

View File

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

View File

@ -953,8 +953,15 @@ def get_objectreference(
Inheritors_T = TypeVar('Inheritors_T')
def inheritors(cls: type[Inheritors_T]) -> set[type[Inheritors_T]]:
"""Return all classes that are subclasses from the supplied cls."""
def inheritors(
cls: type[Inheritors_T], subclasses: bool = True
) -> set[type[Inheritors_T]]:
"""Return all classes that are subclasses from the supplied cls.
Args:
cls: The class to search for subclasses
subclasses: Include subclasses of subclasses (default = True)
"""
subcls = set()
work = [cls]
@ -963,6 +970,7 @@ def inheritors(cls: type[Inheritors_T]) -> set[type[Inheritors_T]]:
for child in parent.__subclasses__():
if child not in subcls:
subcls.add(child)
if subclasses:
work.append(child)
return subcls

View File

@ -363,7 +363,7 @@ class InvenTreeMetadata(SimpleMetadata):
field_info['type'] = 'related field'
field_info['model'] = model._meta.model_name
# Special case for 'user' model
# Special case for special models
if field_info['model'] == 'user':
field_info['api_url'] = '/api/user/'
elif field_info['model'] == 'contenttype':
@ -381,6 +381,14 @@ class InvenTreeMetadata(SimpleMetadata):
if field_info['type'] == 'dependent field':
field_info['depends_on'] = field.depends_on
# Extend field info if the field has a get_field_info method
if (
not field_info.get('read_only')
and hasattr(field, 'get_field_info')
and callable(field.get_field_info)
):
field_info = field.get_field_info(field, field_info)
return field_info

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

View File

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

View File

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

View File

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

View File

@ -60,7 +60,7 @@
<tr>
<td><span class='fas fa-info'></span></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>
<td><span class='fas fa-check-circle'></span></td>

View File

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

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

View File

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

View File

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

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.
"""
from .states import StatusCode
from .states import ColorEnum, StatusCode
from .transition import StateTransitionMixin, TransitionMethod, storage
__all__ = ['StatusCode', 'storage', 'TransitionMethod', 'StateTransitionMixin']
__all__ = [
'ColorEnum',
'StatusCode',
'storage',
'TransitionMethod',
'StateTransitionMixin',
]

View File

@ -2,12 +2,22 @@
import inspect
from django.urls import include, path
from drf_spectacular.utils import OpenApiResponse, extend_schema
from rest_framework import permissions, serializers
from rest_framework.generics import GenericAPIView
from rest_framework.response import Response
import common.models
import common.serializers
from generic.states.custom import get_status_api_response
from importer.mixins import DataExportViewMixin
from InvenTree.filters import SEARCH_ORDER_FILTER
from InvenTree.mixins import ListCreateAPI, RetrieveUpdateDestroyAPI
from InvenTree.permissions import IsStaffOrReadOnly
from InvenTree.serializers import EmptySerializer
from machine.machine_type import MachineStatus
from .states import StatusCode
@ -73,18 +83,52 @@ class AllStatusViews(StatusView):
def get(self, request, *args, **kwargs):
"""Perform a GET request to learn information about status codes."""
data = {}
def discover_status_codes(parent_status_class, prefix=None):
"""Recursively discover status classes."""
for status_class in parent_status_class.__subclasses__():
name = '__'.join([*(prefix or []), status_class.__name__])
data[name] = {
'class': status_class.__name__,
'values': status_class.dict(),
}
discover_status_codes(status_class, [name])
discover_status_codes(StatusCode)
data = get_status_api_response()
# Extend with MachineStatus classes
data.update(get_status_api_response(MachineStatus, prefix=['MachineStatus']))
return Response(data)
# Custom states
class CustomStateList(DataExportViewMixin, ListCreateAPI):
"""List view for all custom states."""
queryset = common.models.InvenTreeCustomUserStateModel.objects.all()
serializer_class = common.serializers.CustomStateSerializer
permission_classes = [permissions.IsAuthenticated, IsStaffOrReadOnly]
filter_backends = SEARCH_ORDER_FILTER
ordering_fields = ['key']
search_fields = ['key', 'name', 'label', 'reference_status']
class CustomStateDetail(RetrieveUpdateDestroyAPI):
"""Detail view for a particular custom states."""
queryset = common.models.InvenTreeCustomUserStateModel.objects.all()
serializer_class = common.serializers.CustomStateSerializer
permission_classes = [permissions.IsAuthenticated, IsStaffOrReadOnly]
urlpattern = [
# Custom state
path(
'custom/',
include([
path(
'<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 re
from enum import Enum
class BaseEnum(enum.IntEnum):
@ -65,10 +66,23 @@ class StatusCode(BaseEnum):
# Normal item definition
if len(args) == 1:
obj.label = args[0]
obj.color = 'secondary'
obj.color = ColorEnum.secondary
else:
obj.label = args[1]
obj.color = args[2] if len(args) > 2 else 'secondary'
obj.color = args[2] if len(args) > 2 else ColorEnum.secondary
# Ensure color is a valid value
if isinstance(obj.color, str):
try:
obj.color = ColorEnum(obj.color)
except ValueError:
raise ValueError(
f"Invalid color value '{obj.color}' for status '{obj.label}'"
)
# Set color value as string
obj.color = obj.color.value
obj.color_class = obj.color
return obj
@ -181,3 +195,15 @@ class StatusCode(BaseEnum):
ret['list'] = cls.list()
return ret
class ColorEnum(Enum):
"""Enum for color values."""
primary = 'primary'
secondary = 'secondary'
success = 'success'
danger = 'danger'
warning = 'warning'
info = 'info'
dark = 'dark'

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -491,7 +491,10 @@ class PrintTestMixins:
def do_activate_plugin(self):
"""Activate the 'samplelabel' plugin."""
config = registry.get_plugin(self.plugin_ref).plugin_config()
plugin = registry.get_plugin(self.plugin_ref)
self.assertIsNotNone(plugin)
config = plugin.plugin_config()
self.assertIsNotNone(config)
config.active = True
config.save()

View File

@ -142,6 +142,7 @@ class StockItemResource(InvenTreeResource):
'barcode_hash',
'barcode_data',
'owner',
'status_custom_key',
]
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.settings import get_global_setting
from company import models as CompanyModels
from generic.states.fields import InvenTreeCustomStatusModelField
from InvenTree.fields import InvenTreeModelMoneyField, InvenTreeURLField
from order.status_codes import SalesOrderStatusGroups
from InvenTree.status_codes import (
SalesOrderStatusGroups,
StockHistoryCode,
StockStatus,
StockStatusGroups,
)
from part import models as PartModels
from plugin.events import trigger_event
from stock import models as StockModels
from stock.generators import generate_batch_code
from stock.status_codes import StockHistoryCode, StockStatus, StockStatusGroups
from users.models import Owner
logger = logging.getLogger('inventree')
@ -940,7 +945,7 @@ class StockItem(
help_text=_('Delete this Stock Item when stock is depleted'),
)
status = models.PositiveIntegerField(
status = InvenTreeCustomStatusModelField(
default=StockStatus.OK.value,
choices=StockStatus.items(),
validators=[MinValueValidator(0)],

View File

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

View File

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

View File

@ -425,7 +425,7 @@
<tr>
<td><span class='fas fa-info'></span></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>
{% if item.expiry_date %}
<tr>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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({
instance
}: {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -301,7 +301,7 @@ export function useCompleteBuildOutputsForm({
};
})
},
status: {},
status_custom_key: {},
location: {
filters: {
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 {
return {
name: {},

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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
}}
/>
</>
);
}