mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
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:
parent
0c63e509d2
commit
d5086b2fb1
15
docs/docs/concepts/custom_states.md
Normal file
15
docs/docs/concepts/custom_states.md
Normal 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)
|
@ -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
|
||||||
|
@ -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.
|
||||||
|
@ -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.
|
||||||
|
@ -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.
|
||||||
|
@ -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>
|
||||||
|
@ -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
|
||||||
|
@ -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)
|
||||||
|
|
||||||
|
@ -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):
|
||||||
|
@ -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,6 +970,7 @@ 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)
|
||||||
|
if subclasses:
|
||||||
work.append(child)
|
work.append(child)
|
||||||
return subcls
|
return subcls
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
@ -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",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
@ -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(),
|
||||||
|
@ -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',
|
||||||
|
@ -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:
|
||||||
|
@ -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 %}
|
||||||
|
@ -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>
|
||||||
|
@ -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/',
|
||||||
|
@ -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")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
@ -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()
|
||||||
|
@ -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."""
|
||||||
|
|
||||||
|
@ -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)'
|
||||||
|
)
|
||||||
|
@ -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',
|
||||||
|
]
|
||||||
|
@ -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'),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
88
src/backend/InvenTree/generic/states/custom.py
Normal file
88
src/backend/InvenTree/generic/states/custom.py
Normal 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()
|
241
src/backend/InvenTree/generic/states/fields.py
Normal file
241
src/backend/InvenTree/generic/states/fields.py
Normal 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
|
@ -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'
|
||||||
|
@ -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)
|
||||||
|
@ -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')
|
||||||
|
@ -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
|
||||||
|
@ -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):
|
||||||
|
@ -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",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
@ -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'),
|
||||||
|
@ -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."""
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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 %}
|
||||||
|
@ -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 %}
|
||||||
|
@ -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 %}
|
||||||
|
@ -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
|
||||||
|
@ -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()
|
||||||
|
|
||||||
|
@ -142,6 +142,7 @@ class StockItemResource(InvenTreeResource):
|
|||||||
'barcode_hash',
|
'barcode_hash',
|
||||||
'barcode_data',
|
'barcode_data',
|
||||||
'owner',
|
'owner',
|
||||||
|
'status_custom_key',
|
||||||
]
|
]
|
||||||
|
|
||||||
id = Field(
|
id = Field(
|
||||||
|
@ -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)],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
@ -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)],
|
||||||
|
@ -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',
|
||||||
|
@ -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:
|
||||||
|
@ -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>
|
||||||
|
@ -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."""
|
||||||
|
|
||||||
|
@ -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) {
|
||||||
|
@ -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',
|
||||||
}
|
}
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -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) {
|
||||||
|
@ -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',
|
||||||
|
@ -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}
|
||||||
|
@ -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
|
||||||
}: {
|
}: {
|
||||||
|
@ -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 = {
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -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}
|
||||||
|
@ -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
|
||||||
|
@ -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'
|
||||||
}
|
}
|
||||||
|
@ -301,7 +301,7 @@ export function useCompleteBuildOutputsForm({
|
|||||||
};
|
};
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
status: {},
|
status_custom_key: {},
|
||||||
location: {
|
location: {
|
||||||
filters: {
|
filters: {
|
||||||
structural: false
|
structural: false
|
||||||
|
@ -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: {},
|
||||||
|
@ -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: {}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -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`,
|
||||||
|
@ -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' }}
|
||||||
/>
|
/>
|
||||||
|
@ -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' }}
|
||||||
/>
|
/>
|
||||||
|
@ -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' }}
|
||||||
/>
|
/>
|
||||||
|
@ -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}
|
||||||
|
@ -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"
|
||||||
|
@ -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')
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
137
src/frontend/src/tables/settings/CustomStateTable.tsx
Normal file
137
src/frontend/src/tables/settings/CustomStateTable.tsx
Normal 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
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user