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
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from generic.states import ColorEnum
|
||||
from plugin.machine import BaseDriver, BaseMachineType, MachineStatus
|
||||
|
||||
class ABCBaseDriver(BaseDriver):
|
||||
@ -72,9 +74,9 @@ class ABCMachine(BaseMachineType):
|
||||
base_driver = ABCBaseDriver
|
||||
|
||||
class ABCStatus(MachineStatus):
|
||||
CONNECTED = 100, _('Connected'), 'success'
|
||||
STANDBY = 101, _('Standby'), 'success'
|
||||
PRINTING = 110, _('Printing'), 'primary'
|
||||
CONNECTED = 100, _('Connected'), ColorEnum.success
|
||||
STANDBY = 101, _('Standby'), ColorEnum.success
|
||||
PRINTING = 110, _('Printing'), ColorEnum.primary
|
||||
|
||||
MACHINE_STATUS = ABCStatus
|
||||
default_machine_status = ABCStatus.DISCONNECTED
|
||||
|
@ -38,6 +38,8 @@ Refer to the source code for the Purchase Order status codes:
|
||||
show_source: True
|
||||
members: []
|
||||
|
||||
Purchase Order Status supports [custom states](../concepts/custom_states.md).
|
||||
|
||||
### Purchase Order Currency
|
||||
|
||||
The currency code can be specified for an individual purchase order. If not specified, the default currency specified against the [supplier](./company.md#suppliers) will be used.
|
||||
|
@ -61,6 +61,8 @@ Refer to the source code for the Return Order status codes:
|
||||
show_source: True
|
||||
members: []
|
||||
|
||||
Return Order Status supports [custom states](../concepts/custom_states.md).
|
||||
|
||||
## Create a Return Order
|
||||
|
||||
From the Return Order index, click on <span class='badge inventree add'><span class='fas fa-plus-circle'></span> New Return Order</span> which opens the "Create Return Order" form.
|
||||
|
@ -39,6 +39,8 @@ Refer to the source code for the Sales Order status codes:
|
||||
show_source: True
|
||||
members: []
|
||||
|
||||
Sales Order Status supports [custom states](../concepts/custom_states.md).
|
||||
|
||||
### Sales Order Currency
|
||||
|
||||
The currency code can be specified for an individual sales order. If not specified, the default currency specified against the [customer](./company.md#customers) will be used.
|
||||
|
@ -10,7 +10,7 @@ Certain stock item status codes will restrict the availability of the stock item
|
||||
|
||||
Below is the list of available stock status codes and their meaning:
|
||||
|
||||
| Status | Description | Available |
|
||||
| Status | Description | Available |
|
||||
| ----------- | ----------- | --- |
|
||||
| <span class='badge inventree success'>OK</span> | Stock item is healthy, nothing wrong to report | <span class='badge inventree success'>Yes</span> |
|
||||
| <span class='badge inventree warning'>Attention needed</span> | Stock item hasn't been checked or tested yet | <span class='badge inventree success'>Yes</span> |
|
||||
@ -38,6 +38,8 @@ Refer to the source code for the Stock status codes:
|
||||
show_source: True
|
||||
members: []
|
||||
|
||||
Stock Status supports [custom states](../concepts/custom_states.md).
|
||||
|
||||
### Default Status Code
|
||||
|
||||
The default status code for any newly created Stock Item is <span class='badge inventree success'>OK</span>
|
||||
|
@ -77,6 +77,7 @@ nav:
|
||||
- Core Concepts:
|
||||
- Terminology: concepts/terminology.md
|
||||
- Physical Units: concepts/units.md
|
||||
- Custom States: concepts/custom_states.md
|
||||
- Development:
|
||||
- Contributing: develop/contributing.md
|
||||
- Devcontainer: develop/devcontainer.md
|
||||
|
@ -1,13 +1,17 @@
|
||||
"""InvenTree API version information."""
|
||||
|
||||
# InvenTree API version
|
||||
INVENTREE_API_VERSION = 245
|
||||
INVENTREE_API_VERSION = 246
|
||||
|
||||
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||
|
||||
|
||||
INVENTREE_API_TEXT = """
|
||||
|
||||
v246 - 2024-08-21 : https://github.com/inventree/InvenTree/pull/7862
|
||||
- Adds custom status fields to various serializers
|
||||
- Adds endpoints to admin custom status fields
|
||||
|
||||
v245 - 2024-08-21 : https://github.com/inventree/InvenTree/pull/7520
|
||||
- Documented pagination fields (no functional changes)
|
||||
|
||||
|
@ -3,9 +3,9 @@
|
||||
"""Provides extra global data to all templates."""
|
||||
|
||||
import InvenTree.email
|
||||
import InvenTree.ready
|
||||
import InvenTree.status
|
||||
from generic.states import StatusCode
|
||||
from InvenTree.helpers import inheritors
|
||||
from generic.states.custom import get_custom_classes
|
||||
from users.models import RuleSet, check_user_role
|
||||
|
||||
|
||||
@ -53,7 +53,10 @@ def status_codes(request):
|
||||
return {}
|
||||
|
||||
request._inventree_status_codes = True
|
||||
return {cls.__name__: cls.template_context() for cls in inheritors(StatusCode)}
|
||||
get_custom = InvenTree.ready.isRebuildingData() is False
|
||||
return {
|
||||
cls.__name__: cls.template_context() for cls in get_custom_classes(get_custom)
|
||||
}
|
||||
|
||||
|
||||
def user_roles(request):
|
||||
|
@ -953,8 +953,15 @@ def get_objectreference(
|
||||
Inheritors_T = TypeVar('Inheritors_T')
|
||||
|
||||
|
||||
def inheritors(cls: type[Inheritors_T]) -> set[type[Inheritors_T]]:
|
||||
"""Return all classes that are subclasses from the supplied cls."""
|
||||
def inheritors(
|
||||
cls: type[Inheritors_T], subclasses: bool = True
|
||||
) -> set[type[Inheritors_T]]:
|
||||
"""Return all classes that are subclasses from the supplied cls.
|
||||
|
||||
Args:
|
||||
cls: The class to search for subclasses
|
||||
subclasses: Include subclasses of subclasses (default = True)
|
||||
"""
|
||||
subcls = set()
|
||||
work = [cls]
|
||||
|
||||
@ -963,7 +970,8 @@ def inheritors(cls: type[Inheritors_T]) -> set[type[Inheritors_T]]:
|
||||
for child in parent.__subclasses__():
|
||||
if child not in subcls:
|
||||
subcls.add(child)
|
||||
work.append(child)
|
||||
if subclasses:
|
||||
work.append(child)
|
||||
return subcls
|
||||
|
||||
|
||||
|
@ -363,7 +363,7 @@ class InvenTreeMetadata(SimpleMetadata):
|
||||
field_info['type'] = 'related field'
|
||||
field_info['model'] = model._meta.model_name
|
||||
|
||||
# Special case for 'user' model
|
||||
# Special case for special models
|
||||
if field_info['model'] == 'user':
|
||||
field_info['api_url'] = '/api/user/'
|
||||
elif field_info['model'] == 'contenttype':
|
||||
@ -381,6 +381,14 @@ class InvenTreeMetadata(SimpleMetadata):
|
||||
if field_info['type'] == 'dependent field':
|
||||
field_info['depends_on'] = field.depends_on
|
||||
|
||||
# Extend field info if the field has a get_field_info method
|
||||
if (
|
||||
not field_info.get('read_only')
|
||||
and hasattr(field, 'get_field_info')
|
||||
and callable(field.get_field_info)
|
||||
):
|
||||
field_info = field.get_field_info(field, field_info)
|
||||
|
||||
return field_info
|
||||
|
||||
|
||||
|
@ -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 stock.models
|
||||
import users.models
|
||||
import generic.states
|
||||
|
||||
|
||||
logger = logging.getLogger('inventree')
|
||||
@ -315,7 +316,7 @@ class Build(
|
||||
help_text=_('Number of stock items which have been completed')
|
||||
)
|
||||
|
||||
status = models.PositiveIntegerField(
|
||||
status = generic.states.fields.InvenTreeCustomStatusModelField(
|
||||
verbose_name=_('Build Status'),
|
||||
default=BuildStatus.PENDING.value,
|
||||
choices=BuildStatus.items(),
|
||||
|
@ -2,45 +2,53 @@
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
from django.db import transaction
|
||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from django.db import models
|
||||
from django.db.models import ExpressionWrapper, F, FloatField
|
||||
from django.db.models import Case, Sum, When, Value
|
||||
from django.db.models import BooleanField, Q
|
||||
from django.db import models, transaction
|
||||
from django.db.models import (
|
||||
BooleanField,
|
||||
Case,
|
||||
ExpressionWrapper,
|
||||
F,
|
||||
FloatField,
|
||||
Q,
|
||||
Sum,
|
||||
Value,
|
||||
When,
|
||||
)
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from rest_framework import serializers
|
||||
from rest_framework.serializers import ValidationError
|
||||
|
||||
from InvenTree.serializers import InvenTreeModelSerializer, UserSerializer
|
||||
|
||||
import InvenTree.helpers
|
||||
import InvenTree.tasks
|
||||
from InvenTree.serializers import InvenTreeDecimalField, NotesFieldMixin
|
||||
from stock.status_codes import StockStatus
|
||||
|
||||
from stock.generators import generate_batch_code
|
||||
from stock.models import StockItem, StockLocation
|
||||
from stock.serializers import StockItemSerializerBrief, LocationBriefSerializer
|
||||
|
||||
import build.tasks
|
||||
import common.models
|
||||
from common.serializers import ProjectCodeSerializer
|
||||
from common.settings import get_global_setting
|
||||
from importer.mixins import DataImportExportSerializerMixin
|
||||
import company.serializers
|
||||
import InvenTree.helpers
|
||||
import InvenTree.tasks
|
||||
import part.filters
|
||||
import part.serializers as part_serializers
|
||||
from common.serializers import ProjectCodeSerializer
|
||||
from common.settings import get_global_setting
|
||||
from generic.states.fields import InvenTreeCustomStatusSerializerMixin
|
||||
from importer.mixins import DataImportExportSerializerMixin
|
||||
from InvenTree.serializers import (
|
||||
InvenTreeDecimalField,
|
||||
InvenTreeModelSerializer,
|
||||
NotesFieldMixin,
|
||||
UserSerializer,
|
||||
)
|
||||
from stock.generators import generate_batch_code
|
||||
from stock.models import StockItem, StockLocation
|
||||
from stock.serializers import LocationBriefSerializer, StockItemSerializerBrief
|
||||
from stock.status_codes import StockStatus
|
||||
from users.serializers import OwnerSerializer
|
||||
|
||||
from .models import Build, BuildLine, BuildItem
|
||||
from .models import Build, BuildItem, BuildLine
|
||||
from .status_codes import BuildStatus
|
||||
|
||||
|
||||
class BuildSerializer(NotesFieldMixin, DataImportExportSerializerMixin, InvenTreeModelSerializer):
|
||||
class BuildSerializer(NotesFieldMixin, DataImportExportSerializerMixin, InvenTreeCustomStatusSerializerMixin, InvenTreeModelSerializer):
|
||||
"""Serializes a Build object."""
|
||||
|
||||
class Meta:
|
||||
@ -69,6 +77,7 @@ class BuildSerializer(NotesFieldMixin, DataImportExportSerializerMixin, InvenTre
|
||||
'quantity',
|
||||
'status',
|
||||
'status_text',
|
||||
'status_custom_key',
|
||||
'target_date',
|
||||
'take_from',
|
||||
'notes',
|
||||
|
@ -2,17 +2,17 @@
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from generic.states import StatusCode
|
||||
from generic.states import ColorEnum, StatusCode
|
||||
|
||||
|
||||
class BuildStatus(StatusCode):
|
||||
"""Build status codes."""
|
||||
|
||||
PENDING = 10, _('Pending'), 'secondary' # Build is pending / active
|
||||
PRODUCTION = 20, _('Production'), 'primary' # Build is in production
|
||||
ON_HOLD = 25, _('On Hold'), 'warning' # Build is on hold
|
||||
CANCELLED = 30, _('Cancelled'), 'danger' # Build was cancelled
|
||||
COMPLETE = 40, _('Complete'), 'success' # Build is complete
|
||||
PENDING = 10, _('Pending'), ColorEnum.secondary # Build is pending / active
|
||||
PRODUCTION = 20, _('Production'), ColorEnum.primary # Build is in production
|
||||
ON_HOLD = 25, _('On Hold'), ColorEnum.warning # Build is on hold
|
||||
CANCELLED = 30, _('Cancelled'), ColorEnum.danger # Build was cancelled
|
||||
COMPLETE = 40, _('Complete'), ColorEnum.success # Build is complete
|
||||
|
||||
|
||||
class BuildStatusGroups:
|
||||
|
@ -158,7 +158,7 @@ src="{% static 'img/blank_image.png' %}"
|
||||
<td><span class='fas fa-info'></span></td>
|
||||
<td>{% trans "Status" %}</td>
|
||||
<td>
|
||||
{% status_label 'build' build.status %}
|
||||
{% display_status_label 'build' build.status_custom_key build.status %}
|
||||
</td>
|
||||
</tr>
|
||||
{% if build.target_date %}
|
||||
@ -225,7 +225,7 @@ src="{% static 'img/blank_image.png' %}"
|
||||
|
||||
{% block page_data %}
|
||||
<h3>
|
||||
{% status_label 'build' build.status large=True %}
|
||||
{% display_status_label 'build' build.status_custom_key build.status large=True %}
|
||||
{% if build.is_overdue %}
|
||||
<span class='badge rounded-pill bg-danger'>{% trans "Overdue" %}</span>
|
||||
{% endif %}
|
||||
|
@ -60,7 +60,7 @@
|
||||
<tr>
|
||||
<td><span class='fas fa-info'></span></td>
|
||||
<td>{% trans "Status" %}</td>
|
||||
<td>{% status_label 'build' build.status %}</td>
|
||||
<td>{% display_status_label 'build' build.status_custom_key build.status %}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class='fas fa-check-circle'></span></td>
|
||||
|
@ -29,7 +29,7 @@ import common.models
|
||||
import common.serializers
|
||||
from common.icons import get_icon_packs
|
||||
from common.settings import get_global_setting
|
||||
from generic.states.api import AllStatusViews, StatusView
|
||||
from generic.states.api import urlpattern as generic_states_api_urls
|
||||
from importer.mixins import DataExportViewMixin
|
||||
from InvenTree.api import BulkDeleteMixin, MetadataView
|
||||
from InvenTree.config import CONFIG_LOOKUPS
|
||||
@ -655,6 +655,8 @@ class ContentTypeList(ListAPI):
|
||||
queryset = ContentType.objects.all()
|
||||
serializer_class = common.serializers.ContentTypeSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
filter_backends = SEARCH_ORDER_FILTER
|
||||
search_fields = ['app_label', 'model']
|
||||
|
||||
|
||||
class ContentTypeDetail(RetrieveAPI):
|
||||
@ -965,16 +967,7 @@ common_api_urls = [
|
||||
]),
|
||||
),
|
||||
# Status
|
||||
path(
|
||||
'generic/status/',
|
||||
include([
|
||||
path(
|
||||
f'<str:{StatusView.MODEL_REF}>/',
|
||||
include([path('', StatusView.as_view(), name='api-status')]),
|
||||
),
|
||||
path('', AllStatusViews.as_view(), name='api-status-all'),
|
||||
]),
|
||||
),
|
||||
path('generic/status/', include(generic_states_api_urls)),
|
||||
# Contenttype
|
||||
path(
|
||||
'contenttype/',
|
||||
|
@ -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 report.helpers
|
||||
import users.models
|
||||
from generic.states import ColorEnum
|
||||
from generic.states.custom import get_custom_classes, state_color_mappings
|
||||
from InvenTree.sanitizer import sanitize_svg
|
||||
from plugin import registry
|
||||
|
||||
@ -3339,3 +3341,109 @@ class Attachment(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel
|
||||
raise ValidationError(_('Invalid model type specified for attachment'))
|
||||
|
||||
return model_class.check_attachment_permission(permission, user)
|
||||
|
||||
|
||||
class InvenTreeCustomUserStateModel(models.Model):
|
||||
"""Custom model to extends any registered state with extra custom, user defined states."""
|
||||
|
||||
key = models.IntegerField(
|
||||
verbose_name=_('Key'),
|
||||
help_text=_('Value that will be saved in the models database'),
|
||||
)
|
||||
name = models.CharField(
|
||||
max_length=250, verbose_name=_('Name'), help_text=_('Name of the state')
|
||||
)
|
||||
label = models.CharField(
|
||||
max_length=250,
|
||||
verbose_name=_('label'),
|
||||
help_text=_('Label that will be displayed in the frontend'),
|
||||
)
|
||||
color = models.CharField(
|
||||
max_length=10,
|
||||
choices=state_color_mappings(),
|
||||
default=ColorEnum.secondary.value,
|
||||
verbose_name=_('Color'),
|
||||
help_text=_('Color that will be displayed in the frontend'),
|
||||
)
|
||||
logical_key = models.IntegerField(
|
||||
verbose_name=_('Logical Key'),
|
||||
help_text=_(
|
||||
'State logical key that is equal to this custom state in business logic'
|
||||
),
|
||||
)
|
||||
model = models.ForeignKey(
|
||||
ContentType,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name=_('Model'),
|
||||
help_text=_('Model this state is associated with'),
|
||||
)
|
||||
reference_status = models.CharField(
|
||||
max_length=250,
|
||||
verbose_name=_('Reference Status Set'),
|
||||
help_text=_('Status set that is extended with this custom state'),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options for this mixin."""
|
||||
|
||||
verbose_name = _('Custom State')
|
||||
verbose_name_plural = _('Custom States')
|
||||
unique_together = [['model', 'reference_status', 'key', 'logical_key']]
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""Return string representation of the custom state."""
|
||||
return f'{self.model.name} ({self.reference_status}): {self.name} | {self.key} ({self.logical_key})'
|
||||
|
||||
def save(self, *args, **kwargs) -> None:
|
||||
"""Ensure that the custom state is valid before saving."""
|
||||
self.clean()
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
def clean(self) -> None:
|
||||
"""Validate custom state data."""
|
||||
if self.model is None:
|
||||
raise ValidationError({'model': _('Model must be selected')})
|
||||
|
||||
if self.key is None:
|
||||
raise ValidationError({'key': _('Key must be selected')})
|
||||
|
||||
if self.logical_key is None:
|
||||
raise ValidationError({'logical_key': _('Logical key must be selected')})
|
||||
|
||||
# Ensure that the key is not the same as the logical key
|
||||
if self.key == self.logical_key:
|
||||
raise ValidationError({'key': _('Key must be different from logical key')})
|
||||
|
||||
if self.reference_status is None or self.reference_status == '':
|
||||
raise ValidationError({
|
||||
'reference_status': _('Reference status must be selected')
|
||||
})
|
||||
|
||||
# Ensure that the key is not in the range of the logical keys of the reference status
|
||||
ref_set = list(
|
||||
filter(
|
||||
lambda x: x.__name__ == self.reference_status,
|
||||
get_custom_classes(include_custom=False),
|
||||
)
|
||||
)
|
||||
if len(ref_set) == 0:
|
||||
raise ValidationError({
|
||||
'reference_status': _('Reference status set not found')
|
||||
})
|
||||
ref_set = ref_set[0]
|
||||
if self.key in ref_set.keys():
|
||||
raise ValidationError({
|
||||
'key': _(
|
||||
'Key must be different from the logical keys of the reference status'
|
||||
)
|
||||
})
|
||||
if self.logical_key not in ref_set.keys():
|
||||
raise ValidationError({
|
||||
'logical_key': _(
|
||||
'Logical key must be in the logical keys of the reference status'
|
||||
)
|
||||
})
|
||||
|
||||
return super().clean()
|
||||
|
@ -14,6 +14,7 @@ from taggit.serializers import TagListSerializerField
|
||||
|
||||
import common.models as common_models
|
||||
import common.validators
|
||||
import generic.states.custom
|
||||
from importer.mixins import DataImportExportSerializerMixin
|
||||
from importer.registry import register_importer
|
||||
from InvenTree.helpers import get_objectreference
|
||||
@ -308,6 +309,32 @@ class ProjectCodeSerializer(DataImportExportSerializerMixin, InvenTreeModelSeria
|
||||
responsible_detail = OwnerSerializer(source='responsible', read_only=True)
|
||||
|
||||
|
||||
@register_importer()
|
||||
class CustomStateSerializer(DataImportExportSerializerMixin, InvenTreeModelSerializer):
|
||||
"""Serializer for the custom state model."""
|
||||
|
||||
class Meta:
|
||||
"""Meta options for CustomStateSerializer."""
|
||||
|
||||
model = common_models.InvenTreeCustomUserStateModel
|
||||
fields = [
|
||||
'pk',
|
||||
'key',
|
||||
'name',
|
||||
'label',
|
||||
'color',
|
||||
'logical_key',
|
||||
'model',
|
||||
'model_name',
|
||||
'reference_status',
|
||||
]
|
||||
|
||||
model_name = serializers.CharField(read_only=True, source='model.name')
|
||||
reference_status = serializers.ChoiceField(
|
||||
choices=generic.states.custom.state_reference_mappings()
|
||||
)
|
||||
|
||||
|
||||
class FlagSerializer(serializers.Serializer):
|
||||
"""Serializer for feature flags."""
|
||||
|
||||
|
@ -33,6 +33,7 @@ from .models import (
|
||||
Attachment,
|
||||
ColorTheme,
|
||||
CustomUnit,
|
||||
InvenTreeCustomUserStateModel,
|
||||
InvenTreeSetting,
|
||||
InvenTreeUserSetting,
|
||||
NotesImage,
|
||||
@ -1586,3 +1587,93 @@ class ValidatorsTest(TestCase):
|
||||
common.validators.validate_icon('ti:package:non-existing-variant')
|
||||
|
||||
common.validators.validate_icon('ti:package:outline')
|
||||
|
||||
|
||||
class CustomStatusTest(TestCase):
|
||||
"""Unit tests for the custom status model."""
|
||||
|
||||
def setUp(self):
|
||||
"""Setup for all tests."""
|
||||
self.data = {
|
||||
'key': 11,
|
||||
'name': 'OK - advanced',
|
||||
'label': 'OK - adv.',
|
||||
'color': 'secondary',
|
||||
'logical_key': 10,
|
||||
'model': ContentType.objects.get(model='stockitem'),
|
||||
'reference_status': 'StockStatus',
|
||||
}
|
||||
|
||||
def test_validation_model(self):
|
||||
"""Test that model is present."""
|
||||
data = self.data
|
||||
data.pop('model')
|
||||
with self.assertRaises(ValidationError):
|
||||
InvenTreeCustomUserStateModel.objects.create(**data)
|
||||
self.assertEqual(InvenTreeCustomUserStateModel.objects.count(), 0)
|
||||
|
||||
def test_validation_key(self):
|
||||
"""Tests Model must have a key."""
|
||||
data = self.data
|
||||
data.pop('key')
|
||||
with self.assertRaises(ValidationError):
|
||||
InvenTreeCustomUserStateModel.objects.create(**data)
|
||||
self.assertEqual(InvenTreeCustomUserStateModel.objects.count(), 0)
|
||||
|
||||
def test_validation_logicalkey(self):
|
||||
"""Tests Logical key must be present."""
|
||||
data = self.data
|
||||
data.pop('logical_key')
|
||||
with self.assertRaises(ValidationError):
|
||||
InvenTreeCustomUserStateModel.objects.create(**data)
|
||||
self.assertEqual(InvenTreeCustomUserStateModel.objects.count(), 0)
|
||||
|
||||
def test_validation_reference(self):
|
||||
"""Tests Reference status must be present."""
|
||||
data = self.data
|
||||
data.pop('reference_status')
|
||||
with self.assertRaises(ValidationError):
|
||||
InvenTreeCustomUserStateModel.objects.create(**data)
|
||||
self.assertEqual(InvenTreeCustomUserStateModel.objects.count(), 0)
|
||||
|
||||
def test_validation_logical_unique(self):
|
||||
"""Tests Logical key must be unique."""
|
||||
data = self.data
|
||||
data['logical_key'] = data['key']
|
||||
with self.assertRaises(ValidationError):
|
||||
InvenTreeCustomUserStateModel.objects.create(**data)
|
||||
self.assertEqual(InvenTreeCustomUserStateModel.objects.count(), 0)
|
||||
|
||||
def test_validation_reference_exsists(self):
|
||||
"""Tests Reference status set not found."""
|
||||
data = self.data
|
||||
data['reference_status'] = 'abcd'
|
||||
with self.assertRaises(ValidationError):
|
||||
InvenTreeCustomUserStateModel.objects.create(**data)
|
||||
self.assertEqual(InvenTreeCustomUserStateModel.objects.count(), 0)
|
||||
|
||||
def test_validation_key_unique(self):
|
||||
"""Tests Key must be different from the logical keys of the reference."""
|
||||
data = self.data
|
||||
data['key'] = 50
|
||||
with self.assertRaises(ValidationError):
|
||||
InvenTreeCustomUserStateModel.objects.create(**data)
|
||||
self.assertEqual(InvenTreeCustomUserStateModel.objects.count(), 0)
|
||||
|
||||
def test_validation_logical_key_exsists(self):
|
||||
"""Tests Logical key must be in the logical keys of the reference status."""
|
||||
data = self.data
|
||||
data['logical_key'] = 12
|
||||
with self.assertRaises(ValidationError):
|
||||
InvenTreeCustomUserStateModel.objects.create(**data)
|
||||
self.assertEqual(InvenTreeCustomUserStateModel.objects.count(), 0)
|
||||
|
||||
def test_validation(self):
|
||||
"""Tests Valid run."""
|
||||
data = self.data
|
||||
instance = InvenTreeCustomUserStateModel.objects.create(**data)
|
||||
self.assertEqual(data['key'], instance.key)
|
||||
self.assertEqual(InvenTreeCustomUserStateModel.objects.count(), 1)
|
||||
self.assertEqual(
|
||||
instance.__str__(), 'Stock Item (StockStatus): OK - advanced | 11 (10)'
|
||||
)
|
||||
|
@ -6,7 +6,13 @@ There is a rendered state for each state value. The rendered state is used for d
|
||||
States can be extended with custom options for each InvenTree instance - those options are stored in the database and need to link back to state values.
|
||||
"""
|
||||
|
||||
from .states import StatusCode
|
||||
from .states import ColorEnum, StatusCode
|
||||
from .transition import StateTransitionMixin, TransitionMethod, storage
|
||||
|
||||
__all__ = ['StatusCode', 'storage', 'TransitionMethod', 'StateTransitionMixin']
|
||||
__all__ = [
|
||||
'ColorEnum',
|
||||
'StatusCode',
|
||||
'storage',
|
||||
'TransitionMethod',
|
||||
'StateTransitionMixin',
|
||||
]
|
||||
|
@ -2,12 +2,22 @@
|
||||
|
||||
import inspect
|
||||
|
||||
from django.urls import include, path
|
||||
|
||||
from drf_spectacular.utils import OpenApiResponse, extend_schema
|
||||
from rest_framework import permissions, serializers
|
||||
from rest_framework.generics import GenericAPIView
|
||||
from rest_framework.response import Response
|
||||
|
||||
import common.models
|
||||
import common.serializers
|
||||
from generic.states.custom import get_status_api_response
|
||||
from importer.mixins import DataExportViewMixin
|
||||
from InvenTree.filters import SEARCH_ORDER_FILTER
|
||||
from InvenTree.mixins import ListCreateAPI, RetrieveUpdateDestroyAPI
|
||||
from InvenTree.permissions import IsStaffOrReadOnly
|
||||
from InvenTree.serializers import EmptySerializer
|
||||
from machine.machine_type import MachineStatus
|
||||
|
||||
from .states import StatusCode
|
||||
|
||||
@ -73,18 +83,52 @@ class AllStatusViews(StatusView):
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
"""Perform a GET request to learn information about status codes."""
|
||||
data = {}
|
||||
|
||||
def discover_status_codes(parent_status_class, prefix=None):
|
||||
"""Recursively discover status classes."""
|
||||
for status_class in parent_status_class.__subclasses__():
|
||||
name = '__'.join([*(prefix or []), status_class.__name__])
|
||||
data[name] = {
|
||||
'class': status_class.__name__,
|
||||
'values': status_class.dict(),
|
||||
}
|
||||
discover_status_codes(status_class, [name])
|
||||
|
||||
discover_status_codes(StatusCode)
|
||||
|
||||
data = get_status_api_response()
|
||||
# Extend with MachineStatus classes
|
||||
data.update(get_status_api_response(MachineStatus, prefix=['MachineStatus']))
|
||||
return Response(data)
|
||||
|
||||
|
||||
# Custom states
|
||||
class CustomStateList(DataExportViewMixin, ListCreateAPI):
|
||||
"""List view for all custom states."""
|
||||
|
||||
queryset = common.models.InvenTreeCustomUserStateModel.objects.all()
|
||||
serializer_class = common.serializers.CustomStateSerializer
|
||||
permission_classes = [permissions.IsAuthenticated, IsStaffOrReadOnly]
|
||||
filter_backends = SEARCH_ORDER_FILTER
|
||||
ordering_fields = ['key']
|
||||
search_fields = ['key', 'name', 'label', 'reference_status']
|
||||
|
||||
|
||||
class CustomStateDetail(RetrieveUpdateDestroyAPI):
|
||||
"""Detail view for a particular custom states."""
|
||||
|
||||
queryset = common.models.InvenTreeCustomUserStateModel.objects.all()
|
||||
serializer_class = common.serializers.CustomStateSerializer
|
||||
permission_classes = [permissions.IsAuthenticated, IsStaffOrReadOnly]
|
||||
|
||||
|
||||
urlpattern = [
|
||||
# Custom state
|
||||
path(
|
||||
'custom/',
|
||||
include([
|
||||
path(
|
||||
'<int:pk>/', CustomStateDetail.as_view(), name='api-custom-state-detail'
|
||||
),
|
||||
path('', CustomStateList.as_view(), name='api-custom-state-list'),
|
||||
]),
|
||||
),
|
||||
# Generic status views
|
||||
path(
|
||||
'',
|
||||
include([
|
||||
path(
|
||||
f'<str:{StatusView.MODEL_REF}>/',
|
||||
include([path('', StatusView.as_view(), name='api-status')]),
|
||||
),
|
||||
path('', AllStatusViews.as_view(), name='api-status-all'),
|
||||
]),
|
||||
),
|
||||
]
|
||||
|
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 re
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class BaseEnum(enum.IntEnum):
|
||||
@ -65,10 +66,23 @@ class StatusCode(BaseEnum):
|
||||
# Normal item definition
|
||||
if len(args) == 1:
|
||||
obj.label = args[0]
|
||||
obj.color = 'secondary'
|
||||
obj.color = ColorEnum.secondary
|
||||
else:
|
||||
obj.label = args[1]
|
||||
obj.color = args[2] if len(args) > 2 else 'secondary'
|
||||
obj.color = args[2] if len(args) > 2 else ColorEnum.secondary
|
||||
|
||||
# Ensure color is a valid value
|
||||
if isinstance(obj.color, str):
|
||||
try:
|
||||
obj.color = ColorEnum(obj.color)
|
||||
except ValueError:
|
||||
raise ValueError(
|
||||
f"Invalid color value '{obj.color}' for status '{obj.label}'"
|
||||
)
|
||||
|
||||
# Set color value as string
|
||||
obj.color = obj.color.value
|
||||
obj.color_class = obj.color
|
||||
|
||||
return obj
|
||||
|
||||
@ -181,3 +195,15 @@ class StatusCode(BaseEnum):
|
||||
ret['list'] = cls.list()
|
||||
|
||||
return ret
|
||||
|
||||
|
||||
class ColorEnum(Enum):
|
||||
"""Enum for color values."""
|
||||
|
||||
primary = 'primary'
|
||||
secondary = 'secondary'
|
||||
success = 'success'
|
||||
danger = 'danger'
|
||||
warning = 'warning'
|
||||
info = 'info'
|
||||
dark = 'dark'
|
||||
|
@ -3,15 +3,21 @@
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
from generic.templatetags.generic import register
|
||||
from InvenTree.helpers import inheritors
|
||||
|
||||
from .states import StatusCode
|
||||
from .custom import get_custom_status_labels
|
||||
|
||||
|
||||
@register.simple_tag
|
||||
def status_label(typ: str, key: int, *args, **kwargs):
|
||||
def status_label(typ: str, key: int, include_custom: bool = False, *args, **kwargs):
|
||||
"""Render a status label."""
|
||||
state = {cls.tag(): cls for cls in inheritors(StatusCode)}.get(typ, None)
|
||||
state = get_custom_status_labels(include_custom=include_custom).get(typ, None)
|
||||
if state:
|
||||
return mark_safe(state.render(key, large=kwargs.get('large', False)))
|
||||
raise ValueError(f"Unknown status type '{typ}'")
|
||||
|
||||
|
||||
@register.simple_tag
|
||||
def display_status_label(typ: str, key: int, fallback: int, *args, **kwargs):
|
||||
"""Render a status label."""
|
||||
render_key = int(key) if key else fallback
|
||||
return status_label(typ, render_key, *args, include_custom=True, **kwargs)
|
||||
|
@ -1,11 +1,15 @@
|
||||
"""Tests for the generic states module."""
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.test.client import RequestFactory
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from rest_framework.test import force_authenticate
|
||||
|
||||
from InvenTree.unit_test import InvenTreeTestCase
|
||||
from common.models import InvenTreeCustomUserStateModel
|
||||
from generic.states import ColorEnum
|
||||
from InvenTree.unit_test import InvenTreeAPITestCase, InvenTreeTestCase
|
||||
|
||||
from .api import StatusView
|
||||
from .states import StatusCode
|
||||
@ -14,9 +18,9 @@ from .states import StatusCode
|
||||
class GeneralStatus(StatusCode):
|
||||
"""Defines a set of status codes for tests."""
|
||||
|
||||
PENDING = 10, _('Pending'), 'secondary'
|
||||
PENDING = 10, _('Pending'), ColorEnum.secondary
|
||||
PLACED = 20, _('Placed'), 'primary'
|
||||
COMPLETE = 30, _('Complete'), 'success'
|
||||
COMPLETE = 30, _('Complete'), ColorEnum.success
|
||||
ABC = None # This should be ignored
|
||||
_DEF = None # This should be ignored
|
||||
jkl = None # This should be ignored
|
||||
@ -120,8 +124,19 @@ class GeneralStateTest(InvenTreeTestCase):
|
||||
# label
|
||||
self.assertEqual(GeneralStatus.label(10), 'Pending')
|
||||
|
||||
def test_tag_function(self):
|
||||
"""Test that the status code tag functions."""
|
||||
def test_color(self):
|
||||
"""Test that the color enum validation works."""
|
||||
with self.assertRaises(ValueError) as e:
|
||||
|
||||
class TTTT(StatusCode):
|
||||
PENDING = 10, _('Pending'), 'invalid'
|
||||
|
||||
self.assertEqual(
|
||||
str(e.exception), "Invalid color value 'invalid' for status 'Pending'"
|
||||
)
|
||||
|
||||
def test_tag_status_label(self):
|
||||
"""Test that the status_label tag."""
|
||||
from .tags import status_label
|
||||
|
||||
self.assertEqual(
|
||||
@ -137,6 +152,21 @@ class GeneralStateTest(InvenTreeTestCase):
|
||||
# Test non-existent key
|
||||
self.assertEqual(status_label('general', 100), '100')
|
||||
|
||||
def test_tag_display_status_label(self):
|
||||
"""Test that the display_status_label tag (mainly the same as status_label)."""
|
||||
from .tags import display_status_label
|
||||
|
||||
self.assertEqual(
|
||||
display_status_label('general', 10, 11),
|
||||
"<span class='badge rounded-pill bg-secondary'>Pending</span>",
|
||||
)
|
||||
# Fallback
|
||||
self.assertEqual(display_status_label('general', None, 11), '11')
|
||||
self.assertEqual(
|
||||
display_status_label('general', None, 10),
|
||||
"<span class='badge rounded-pill bg-secondary'>Pending</span>",
|
||||
)
|
||||
|
||||
def test_api(self):
|
||||
"""Test StatusView API view."""
|
||||
view = StatusView.as_view()
|
||||
@ -191,3 +221,59 @@ class GeneralStateTest(InvenTreeTestCase):
|
||||
self.assertEqual(
|
||||
str(e.exception), '`status_class` not a valid StatusCode class'
|
||||
)
|
||||
|
||||
|
||||
class ApiTests(InvenTreeAPITestCase):
|
||||
"""Test the API for the generic states module."""
|
||||
|
||||
def test_all_states(self):
|
||||
"""Test the API endpoint for listing all status models."""
|
||||
response = self.get(reverse('api-status-all'))
|
||||
self.assertEqual(len(response.data), 12)
|
||||
|
||||
# Test the BuildStatus model
|
||||
build_status = response.data['BuildStatus']
|
||||
self.assertEqual(build_status['class'], 'BuildStatus')
|
||||
self.assertEqual(len(build_status['values']), 5)
|
||||
pending = build_status['values']['PENDING']
|
||||
self.assertEqual(pending['key'], 10)
|
||||
self.assertEqual(pending['name'], 'PENDING')
|
||||
self.assertEqual(pending['label'], 'Pending')
|
||||
|
||||
# Test the StockStatus model (static)
|
||||
stock_status = response.data['StockStatus']
|
||||
self.assertEqual(stock_status['class'], 'StockStatus')
|
||||
self.assertEqual(len(stock_status['values']), 8)
|
||||
in_stock = stock_status['values']['OK']
|
||||
self.assertEqual(in_stock['key'], 10)
|
||||
self.assertEqual(in_stock['name'], 'OK')
|
||||
self.assertEqual(in_stock['label'], 'OK')
|
||||
|
||||
# MachineStatus model
|
||||
machine_status = response.data['MachineStatus__LabelPrinterStatus']
|
||||
self.assertEqual(machine_status['class'], 'LabelPrinterStatus')
|
||||
self.assertEqual(len(machine_status['values']), 6)
|
||||
connected = machine_status['values']['CONNECTED']
|
||||
self.assertEqual(connected['key'], 100)
|
||||
self.assertEqual(connected['name'], 'CONNECTED')
|
||||
|
||||
# Add custom status
|
||||
InvenTreeCustomUserStateModel.objects.create(
|
||||
key=11,
|
||||
name='OK - advanced',
|
||||
label='OK - adv.',
|
||||
color='secondary',
|
||||
logical_key=10,
|
||||
model=ContentType.objects.get(model='stockitem'),
|
||||
reference_status='StockStatus',
|
||||
)
|
||||
response = self.get(reverse('api-status-all'))
|
||||
self.assertEqual(len(response.data), 12)
|
||||
|
||||
stock_status_cstm = response.data['StockStatus']
|
||||
self.assertEqual(stock_status_cstm['class'], 'StockStatus')
|
||||
self.assertEqual(len(stock_status_cstm['values']), 9)
|
||||
ok_advanced = stock_status_cstm['values']['OK']
|
||||
self.assertEqual(ok_advanced['key'], 10)
|
||||
self.assertEqual(ok_advanced['name'], 'OK')
|
||||
self.assertEqual(ok_advanced['label'], 'OK')
|
||||
|
@ -2,18 +2,26 @@
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from generic.states import StatusCode
|
||||
from generic.states import ColorEnum, StatusCode
|
||||
|
||||
|
||||
class DataImportStatusCode(StatusCode):
|
||||
"""Defines a set of status codes for a DataImportSession."""
|
||||
|
||||
INITIAL = 0, _('Initializing'), 'secondary' # Import session has been created
|
||||
MAPPING = 10, _('Mapping Columns'), 'primary' # Import fields are being mapped
|
||||
IMPORTING = 20, _('Importing Data'), 'primary' # Data is being imported
|
||||
INITIAL = (
|
||||
0,
|
||||
_('Initializing'),
|
||||
ColorEnum.secondary,
|
||||
) # Import session has been created
|
||||
MAPPING = (
|
||||
10,
|
||||
_('Mapping Columns'),
|
||||
ColorEnum.primary,
|
||||
) # Import fields are being mapped
|
||||
IMPORTING = 20, _('Importing Data'), ColorEnum.primary # Data is being imported
|
||||
PROCESSING = (
|
||||
30,
|
||||
_('Processing Data'),
|
||||
'primary',
|
||||
ColorEnum.primary,
|
||||
) # Data is being processed by the user
|
||||
COMPLETE = 40, _('Complete'), 'success' # Import has been completed
|
||||
COMPLETE = 40, _('Complete'), ColorEnum.success # Import has been completed
|
||||
|
@ -12,6 +12,7 @@ from PIL.Image import Image
|
||||
from rest_framework import serializers
|
||||
from rest_framework.request import Request
|
||||
|
||||
from generic.states import ColorEnum
|
||||
from machine.machine_type import BaseDriver, BaseMachineType, MachineStatus
|
||||
from plugin import registry as plg_registry
|
||||
from plugin.base.label.mixins import LabelPrintingMixin
|
||||
@ -228,12 +229,12 @@ class LabelPrinterStatus(MachineStatus):
|
||||
DISCONNECTED: The driver cannot establish a connection to the printer
|
||||
"""
|
||||
|
||||
CONNECTED = 100, _('Connected'), 'success'
|
||||
UNKNOWN = 101, _('Unknown'), 'secondary'
|
||||
PRINTING = 110, _('Printing'), 'primary'
|
||||
NO_MEDIA = 301, _('No media'), 'warning'
|
||||
PAPER_JAM = 302, _('Paper jam'), 'warning'
|
||||
DISCONNECTED = 400, _('Disconnected'), 'danger'
|
||||
CONNECTED = 100, _('Connected'), ColorEnum.success
|
||||
UNKNOWN = 101, _('Unknown'), ColorEnum.secondary
|
||||
PRINTING = 110, _('Printing'), ColorEnum.primary
|
||||
NO_MEDIA = 301, _('No media'), ColorEnum.warning
|
||||
PAPER_JAM = 302, _('Paper jam'), ColorEnum.warning
|
||||
DISCONNECTED = 400, _('Disconnected'), ColorEnum.danger
|
||||
|
||||
|
||||
class LabelPrinterMachine(BaseMachineType):
|
||||
|
@ -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 company.models import Address, Company, Contact, SupplierPart
|
||||
from generic.states import StateTransitionMixin
|
||||
from generic.states.fields import InvenTreeCustomStatusModelField
|
||||
from InvenTree.exceptions import log_error
|
||||
from InvenTree.fields import (
|
||||
InvenTreeModelMoneyField,
|
||||
@ -470,7 +471,7 @@ class PurchaseOrder(TotalPriceMixin, Order):
|
||||
validators=[order.validators.validate_purchase_order_reference],
|
||||
)
|
||||
|
||||
status = models.PositiveIntegerField(
|
||||
status = InvenTreeCustomStatusModelField(
|
||||
default=PurchaseOrderStatus.PENDING.value,
|
||||
choices=PurchaseOrderStatus.items(),
|
||||
verbose_name=_('Status'),
|
||||
@ -996,7 +997,7 @@ class SalesOrder(TotalPriceMixin, Order):
|
||||
"""Accessor helper for Order base."""
|
||||
return self.customer
|
||||
|
||||
status = models.PositiveIntegerField(
|
||||
status = InvenTreeCustomStatusModelField(
|
||||
default=SalesOrderStatus.PENDING.value,
|
||||
choices=SalesOrderStatus.items(),
|
||||
verbose_name=_('Status'),
|
||||
@ -2153,7 +2154,7 @@ class ReturnOrder(TotalPriceMixin, Order):
|
||||
"""Accessor helper for Order base class."""
|
||||
return self.customer
|
||||
|
||||
status = models.PositiveIntegerField(
|
||||
status = InvenTreeCustomStatusModelField(
|
||||
default=ReturnOrderStatus.PENDING.value,
|
||||
choices=ReturnOrderStatus.items(),
|
||||
verbose_name=_('Status'),
|
||||
@ -2404,7 +2405,7 @@ class ReturnOrderLineItem(OrderLineItem):
|
||||
"""Return True if this item has been received."""
|
||||
return self.received_date is not None
|
||||
|
||||
outcome = models.PositiveIntegerField(
|
||||
outcome = InvenTreeCustomStatusModelField(
|
||||
default=ReturnOrderLineStatus.PENDING.value,
|
||||
choices=ReturnOrderLineStatus.items(),
|
||||
verbose_name=_('Outcome'),
|
||||
|
@ -32,6 +32,7 @@ from company.serializers import (
|
||||
ContactSerializer,
|
||||
SupplierPartSerializer,
|
||||
)
|
||||
from generic.states.fields import InvenTreeCustomStatusSerializerMixin
|
||||
from importer.mixins import DataImportExportSerializerMixin
|
||||
from importer.registry import register_importer
|
||||
from InvenTree.helpers import (
|
||||
@ -161,6 +162,7 @@ class AbstractOrderSerializer(DataImportExportSerializerMixin, serializers.Seria
|
||||
'address_detail',
|
||||
'status',
|
||||
'status_text',
|
||||
'status_custom_key',
|
||||
'notes',
|
||||
'barcode_hash',
|
||||
'overdue',
|
||||
@ -216,7 +218,11 @@ class AbstractExtraLineMeta:
|
||||
|
||||
@register_importer()
|
||||
class PurchaseOrderSerializer(
|
||||
NotesFieldMixin, TotalPriceMixin, AbstractOrderSerializer, InvenTreeModelSerializer
|
||||
NotesFieldMixin,
|
||||
TotalPriceMixin,
|
||||
InvenTreeCustomStatusSerializerMixin,
|
||||
AbstractOrderSerializer,
|
||||
InvenTreeModelSerializer,
|
||||
):
|
||||
"""Serializer for a PurchaseOrder object."""
|
||||
|
||||
@ -859,7 +865,11 @@ class PurchaseOrderReceiveSerializer(serializers.Serializer):
|
||||
|
||||
@register_importer()
|
||||
class SalesOrderSerializer(
|
||||
NotesFieldMixin, TotalPriceMixin, AbstractOrderSerializer, InvenTreeModelSerializer
|
||||
NotesFieldMixin,
|
||||
TotalPriceMixin,
|
||||
InvenTreeCustomStatusSerializerMixin,
|
||||
AbstractOrderSerializer,
|
||||
InvenTreeModelSerializer,
|
||||
):
|
||||
"""Serializer for the SalesOrder model class."""
|
||||
|
||||
@ -1642,7 +1652,11 @@ class SalesOrderExtraLineSerializer(
|
||||
|
||||
@register_importer()
|
||||
class ReturnOrderSerializer(
|
||||
NotesFieldMixin, AbstractOrderSerializer, TotalPriceMixin, InvenTreeModelSerializer
|
||||
NotesFieldMixin,
|
||||
InvenTreeCustomStatusSerializerMixin,
|
||||
AbstractOrderSerializer,
|
||||
TotalPriceMixin,
|
||||
InvenTreeModelSerializer,
|
||||
):
|
||||
"""Serializer for the ReturnOrder model class."""
|
||||
|
||||
|
@ -2,20 +2,20 @@
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from generic.states import StatusCode
|
||||
from generic.states import ColorEnum, StatusCode
|
||||
|
||||
|
||||
class PurchaseOrderStatus(StatusCode):
|
||||
"""Defines a set of status codes for a PurchaseOrder."""
|
||||
|
||||
# Order status codes
|
||||
PENDING = 10, _('Pending'), 'secondary' # Order is pending (not yet placed)
|
||||
PLACED = 20, _('Placed'), 'primary' # Order has been placed with supplier
|
||||
ON_HOLD = 25, _('On Hold'), 'warning' # Order is on hold
|
||||
COMPLETE = 30, _('Complete'), 'success' # Order has been completed
|
||||
CANCELLED = 40, _('Cancelled'), 'danger' # Order was cancelled
|
||||
LOST = 50, _('Lost'), 'warning' # Order was lost
|
||||
RETURNED = 60, _('Returned'), 'warning' # Order was returned
|
||||
PENDING = 10, _('Pending'), ColorEnum.secondary # Order is pending (not yet placed)
|
||||
PLACED = 20, _('Placed'), ColorEnum.primary # Order has been placed with supplier
|
||||
ON_HOLD = 25, _('On Hold'), ColorEnum.warning # Order is on hold
|
||||
COMPLETE = 30, _('Complete'), ColorEnum.success # Order has been completed
|
||||
CANCELLED = 40, _('Cancelled'), ColorEnum.danger # Order was cancelled
|
||||
LOST = 50, _('Lost'), ColorEnum.warning # Order was lost
|
||||
RETURNED = 60, _('Returned'), ColorEnum.warning # Order was returned
|
||||
|
||||
|
||||
class PurchaseOrderStatusGroups:
|
||||
@ -39,18 +39,18 @@ class PurchaseOrderStatusGroups:
|
||||
class SalesOrderStatus(StatusCode):
|
||||
"""Defines a set of status codes for a SalesOrder."""
|
||||
|
||||
PENDING = 10, _('Pending'), 'secondary' # Order is pending
|
||||
PENDING = 10, _('Pending'), ColorEnum.secondary # Order is pending
|
||||
IN_PROGRESS = (
|
||||
15,
|
||||
_('In Progress'),
|
||||
'primary',
|
||||
ColorEnum.primary,
|
||||
) # Order has been issued, and is in progress
|
||||
SHIPPED = 20, _('Shipped'), 'success' # Order has been shipped to customer
|
||||
ON_HOLD = 25, _('On Hold'), 'warning' # Order is on hold
|
||||
COMPLETE = 30, _('Complete'), 'success' # Order is complete
|
||||
CANCELLED = 40, _('Cancelled'), 'danger' # Order has been cancelled
|
||||
LOST = 50, _('Lost'), 'warning' # Order was lost
|
||||
RETURNED = 60, _('Returned'), 'warning' # Order was returned
|
||||
SHIPPED = 20, _('Shipped'), ColorEnum.success # Order has been shipped to customer
|
||||
ON_HOLD = 25, _('On Hold'), ColorEnum.warning # Order is on hold
|
||||
COMPLETE = 30, _('Complete'), ColorEnum.success # Order is complete
|
||||
CANCELLED = 40, _('Cancelled'), ColorEnum.danger # Order has been cancelled
|
||||
LOST = 50, _('Lost'), ColorEnum.warning # Order was lost
|
||||
RETURNED = 60, _('Returned'), ColorEnum.warning # Order was returned
|
||||
|
||||
|
||||
class SalesOrderStatusGroups:
|
||||
@ -71,15 +71,15 @@ class ReturnOrderStatus(StatusCode):
|
||||
"""Defines a set of status codes for a ReturnOrder."""
|
||||
|
||||
# Order is pending, waiting for receipt of items
|
||||
PENDING = 10, _('Pending'), 'secondary'
|
||||
PENDING = 10, _('Pending'), ColorEnum.secondary
|
||||
|
||||
# Items have been received, and are being inspected
|
||||
IN_PROGRESS = 20, _('In Progress'), 'primary'
|
||||
IN_PROGRESS = 20, _('In Progress'), ColorEnum.primary
|
||||
|
||||
ON_HOLD = 25, _('On Hold'), 'warning'
|
||||
ON_HOLD = 25, _('On Hold'), ColorEnum.warning
|
||||
|
||||
COMPLETE = 30, _('Complete'), 'success'
|
||||
CANCELLED = 40, _('Cancelled'), 'danger'
|
||||
COMPLETE = 30, _('Complete'), ColorEnum.success
|
||||
CANCELLED = 40, _('Cancelled'), ColorEnum.danger
|
||||
|
||||
|
||||
class ReturnOrderStatusGroups:
|
||||
@ -95,19 +95,19 @@ class ReturnOrderStatusGroups:
|
||||
class ReturnOrderLineStatus(StatusCode):
|
||||
"""Defines a set of status codes for a ReturnOrderLineItem."""
|
||||
|
||||
PENDING = 10, _('Pending'), 'secondary'
|
||||
PENDING = 10, _('Pending'), ColorEnum.secondary
|
||||
|
||||
# Item is to be returned to customer, no other action
|
||||
RETURN = 20, _('Return'), 'success'
|
||||
RETURN = 20, _('Return'), ColorEnum.success
|
||||
|
||||
# Item is to be repaired, and returned to customer
|
||||
REPAIR = 30, _('Repair'), 'primary'
|
||||
REPAIR = 30, _('Repair'), ColorEnum.primary
|
||||
|
||||
# Item is to be replaced (new item shipped)
|
||||
REPLACE = 40, _('Replace'), 'warning'
|
||||
REPLACE = 40, _('Replace'), ColorEnum.warning
|
||||
|
||||
# Item is to be refunded (cannot be repaired)
|
||||
REFUND = 50, _('Refund'), 'info'
|
||||
REFUND = 50, _('Refund'), ColorEnum.info
|
||||
|
||||
# Item is rejected
|
||||
REJECT = 60, _('Reject'), 'danger'
|
||||
REJECT = 60, _('Reject'), ColorEnum.danger
|
||||
|
@ -122,7 +122,7 @@ src="{% static 'img/blank_image.png' %}"
|
||||
<td><span class='fas fa-info'></span></td>
|
||||
<td>{% trans "Order Status" %}</td>
|
||||
<td>
|
||||
{% status_label 'purchase_order' order.status %}
|
||||
{% display_status_label 'purchase_order' order.status_custom_key order.status %}
|
||||
{% if order.is_overdue %}
|
||||
<span class='badge rounded-pill bg-danger'>{% trans "Overdue" %}</span>
|
||||
{% endif %}
|
||||
|
@ -115,7 +115,7 @@ src="{% static 'img/blank_image.png' %}"
|
||||
<td><span class='fas fa-info'></span></td>
|
||||
<td>{% trans "Order Status" %}</td>
|
||||
<td>
|
||||
{% status_label 'return_order' order.status %}
|
||||
{% display_status_label 'return_order' order.status_custom_key order.status %}
|
||||
{% if order.is_overdue %}
|
||||
<span class='badge rounded-pill bg-danger'>{% trans "Overdue" %}</span>
|
||||
{% endif %}
|
||||
|
@ -124,7 +124,7 @@ src="{% static 'img/blank_image.png' %}"
|
||||
<td><span class='fas fa-info'></span></td>
|
||||
<td>{% trans "Order Status" %}</td>
|
||||
<td>
|
||||
{% status_label 'sales_order' order.status %}
|
||||
{% display_status_label 'sales_order' order.status_custom_key order.status %}
|
||||
{% if order.is_overdue %}
|
||||
<span class='badge rounded-pill bg-danger'>{% trans "Overdue" %}</span>
|
||||
{% endif %}
|
||||
|
@ -125,6 +125,7 @@ class LabelMixinTests(PrintTestMixins, InvenTreeAPITestCase):
|
||||
self.assertGreater(len(plugins), 0)
|
||||
|
||||
plugin = registry.get_plugin('samplelabelprinter')
|
||||
self.assertIsNotNone(plugin)
|
||||
config = plugin.plugin_config()
|
||||
|
||||
# Ensure that the plugin is not active
|
||||
|
@ -491,7 +491,10 @@ class PrintTestMixins:
|
||||
|
||||
def do_activate_plugin(self):
|
||||
"""Activate the 'samplelabel' plugin."""
|
||||
config = registry.get_plugin(self.plugin_ref).plugin_config()
|
||||
plugin = registry.get_plugin(self.plugin_ref)
|
||||
self.assertIsNotNone(plugin)
|
||||
config = plugin.plugin_config()
|
||||
self.assertIsNotNone(config)
|
||||
config.active = True
|
||||
config.save()
|
||||
|
||||
|
@ -142,6 +142,7 @@ class StockItemResource(InvenTreeResource):
|
||||
'barcode_hash',
|
||||
'barcode_data',
|
||||
'owner',
|
||||
'status_custom_key',
|
||||
]
|
||||
|
||||
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.settings import get_global_setting
|
||||
from company import models as CompanyModels
|
||||
from generic.states.fields import InvenTreeCustomStatusModelField
|
||||
from InvenTree.fields import InvenTreeModelMoneyField, InvenTreeURLField
|
||||
from order.status_codes import SalesOrderStatusGroups
|
||||
from InvenTree.status_codes import (
|
||||
SalesOrderStatusGroups,
|
||||
StockHistoryCode,
|
||||
StockStatus,
|
||||
StockStatusGroups,
|
||||
)
|
||||
from part import models as PartModels
|
||||
from plugin.events import trigger_event
|
||||
from stock import models as StockModels
|
||||
from stock.generators import generate_batch_code
|
||||
from stock.status_codes import StockHistoryCode, StockStatus, StockStatusGroups
|
||||
from users.models import Owner
|
||||
|
||||
logger = logging.getLogger('inventree')
|
||||
@ -940,7 +945,7 @@ class StockItem(
|
||||
help_text=_('Delete this Stock Item when stock is depleted'),
|
||||
)
|
||||
|
||||
status = models.PositiveIntegerField(
|
||||
status = InvenTreeCustomStatusModelField(
|
||||
default=StockStatus.OK.value,
|
||||
choices=StockStatus.items(),
|
||||
validators=[MinValueValidator(0)],
|
||||
|
@ -27,6 +27,7 @@ import part.serializers as part_serializers
|
||||
import stock.filters
|
||||
import stock.status_codes
|
||||
from common.settings import get_global_setting
|
||||
from generic.states.fields import InvenTreeCustomStatusSerializerMixin
|
||||
from importer.mixins import DataImportExportSerializerMixin
|
||||
from importer.registry import register_importer
|
||||
from InvenTree.serializers import InvenTreeCurrencySerializer, InvenTreeDecimalField
|
||||
@ -326,7 +327,9 @@ class StockItemSerializerBrief(
|
||||
|
||||
@register_importer()
|
||||
class StockItemSerializer(
|
||||
DataImportExportSerializerMixin, InvenTree.serializers.InvenTreeTagModelSerializer
|
||||
DataImportExportSerializerMixin,
|
||||
InvenTreeCustomStatusSerializerMixin,
|
||||
InvenTree.serializers.InvenTreeTagModelSerializer,
|
||||
):
|
||||
"""Serializer for a StockItem.
|
||||
|
||||
@ -373,6 +376,7 @@ class StockItemSerializer(
|
||||
'serial',
|
||||
'status',
|
||||
'status_text',
|
||||
'status_custom_key',
|
||||
'stocktake_date',
|
||||
'supplier_part',
|
||||
'sku',
|
||||
|
@ -2,24 +2,28 @@
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from generic.states import StatusCode
|
||||
from generic.states import ColorEnum, StatusCode
|
||||
|
||||
|
||||
class StockStatus(StatusCode):
|
||||
"""Status codes for Stock."""
|
||||
|
||||
OK = 10, _('OK'), 'success' # Item is OK
|
||||
ATTENTION = 50, _('Attention needed'), 'warning' # Item requires attention
|
||||
DAMAGED = 55, _('Damaged'), 'warning' # Item is damaged
|
||||
DESTROYED = 60, _('Destroyed'), 'danger' # Item is destroyed
|
||||
REJECTED = 65, _('Rejected'), 'danger' # Item is rejected
|
||||
LOST = 70, _('Lost'), 'dark' # Item has been lost
|
||||
OK = 10, _('OK'), ColorEnum.success # Item is OK
|
||||
ATTENTION = 50, _('Attention needed'), ColorEnum.warning # Item requires attention
|
||||
DAMAGED = 55, _('Damaged'), ColorEnum.warning # Item is damaged
|
||||
DESTROYED = 60, _('Destroyed'), ColorEnum.danger # Item is destroyed
|
||||
REJECTED = 65, _('Rejected'), ColorEnum.danger # Item is rejected
|
||||
LOST = 70, _('Lost'), ColorEnum.dark # Item has been lost
|
||||
QUARANTINED = (
|
||||
75,
|
||||
_('Quarantined'),
|
||||
'info',
|
||||
ColorEnum.info,
|
||||
) # Item has been quarantined and is unavailable
|
||||
RETURNED = 85, _('Returned'), 'warning' # Item has been returned from a customer
|
||||
RETURNED = (
|
||||
85,
|
||||
_('Returned'),
|
||||
ColorEnum.warning,
|
||||
) # Item has been returned from a customer
|
||||
|
||||
|
||||
class StockStatusGroups:
|
||||
|
@ -425,7 +425,7 @@
|
||||
<tr>
|
||||
<td><span class='fas fa-info'></span></td>
|
||||
<td>{% trans "Status" %}</td>
|
||||
<td>{% status_label 'stock' item.status %}</td>
|
||||
<td>{% display_status_label 'stock' item.status_custom_key item.status %}</td>
|
||||
</tr>
|
||||
{% if item.expiry_date %}
|
||||
<tr>
|
||||
|
@ -7,6 +7,7 @@ from datetime import datetime, timedelta
|
||||
from enum import IntEnum
|
||||
|
||||
import django.http
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.urls import reverse
|
||||
|
||||
@ -17,7 +18,7 @@ from rest_framework import status
|
||||
import build.models
|
||||
import company.models
|
||||
import part.models
|
||||
from common.models import InvenTreeSetting
|
||||
from common.models import InvenTreeCustomUserStateModel, InvenTreeSetting
|
||||
from InvenTree.unit_test import InvenTreeAPITestCase
|
||||
from part.models import Part, PartTestTemplate
|
||||
from stock.models import (
|
||||
@ -925,6 +926,100 @@ class StockItemListTest(StockAPITestCase):
|
||||
)
|
||||
|
||||
|
||||
class CustomStockItemStatusTest(StockAPITestCase):
|
||||
"""Tests for custom stock item statuses."""
|
||||
|
||||
list_url = reverse('api-stock-list')
|
||||
|
||||
def setUp(self):
|
||||
"""Setup for all tests."""
|
||||
super().setUp()
|
||||
self.status = InvenTreeCustomUserStateModel.objects.create(
|
||||
key=11,
|
||||
name='OK - advanced',
|
||||
label='OK - adv.',
|
||||
color='secondary',
|
||||
logical_key=10,
|
||||
model=ContentType.objects.get(model='stockitem'),
|
||||
reference_status='StockStatus',
|
||||
)
|
||||
self.status2 = InvenTreeCustomUserStateModel.objects.create(
|
||||
key=51,
|
||||
name='attention 2',
|
||||
label='attention 2',
|
||||
color='secondary',
|
||||
logical_key=50,
|
||||
model=ContentType.objects.get(model='stockitem'),
|
||||
reference_status='StockStatus',
|
||||
)
|
||||
|
||||
def test_custom_status(self):
|
||||
"""Tests interaction with states."""
|
||||
# Create a stock item with the custom status code via the API
|
||||
response = self.post(
|
||||
self.list_url,
|
||||
{
|
||||
'name': 'Test Type 1',
|
||||
'description': 'Test desc 1',
|
||||
'quantity': 1,
|
||||
'part': 1,
|
||||
'status_custom_key': self.status.key,
|
||||
},
|
||||
expected_code=201,
|
||||
)
|
||||
self.assertEqual(response.data['status'], self.status.logical_key)
|
||||
self.assertEqual(response.data['status_custom_key'], self.status.key)
|
||||
pk = response.data['pk']
|
||||
|
||||
# Update the stock item with another custom status code via the API
|
||||
response = self.patch(
|
||||
reverse('api-stock-detail', kwargs={'pk': pk}),
|
||||
{'status_custom_key': self.status2.key},
|
||||
expected_code=200,
|
||||
)
|
||||
self.assertEqual(response.data['status'], self.status2.logical_key)
|
||||
self.assertEqual(response.data['status_custom_key'], self.status2.key)
|
||||
|
||||
# Try if status_custom_key is rewrite with status bying set
|
||||
response = self.patch(
|
||||
reverse('api-stock-detail', kwargs={'pk': pk}),
|
||||
{'status': self.status.logical_key},
|
||||
expected_code=200,
|
||||
)
|
||||
self.assertEqual(response.data['status'], self.status.logical_key)
|
||||
self.assertEqual(response.data['status_custom_key'], self.status.logical_key)
|
||||
|
||||
# Create a stock item with a normal status code via the API
|
||||
response = self.post(
|
||||
self.list_url,
|
||||
{
|
||||
'name': 'Test Type 1',
|
||||
'description': 'Test desc 1',
|
||||
'quantity': 1,
|
||||
'part': 1,
|
||||
'status_key': self.status.key,
|
||||
},
|
||||
expected_code=201,
|
||||
)
|
||||
self.assertEqual(response.data['status'], self.status.logical_key)
|
||||
self.assertEqual(response.data['status_custom_key'], self.status.logical_key)
|
||||
|
||||
def test_options(self):
|
||||
"""Test the StockItem OPTIONS endpoint to contain custom StockStatuses."""
|
||||
response = self.options(self.list_url)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# Check that the response contains the custom StockStatuses
|
||||
actions = response.data['actions']['POST']
|
||||
self.assertIn('status_custom_key', actions)
|
||||
status_custom_key = actions['status_custom_key']
|
||||
self.assertEqual(len(status_custom_key['choices']), 10)
|
||||
status = status_custom_key['choices'][1]
|
||||
self.assertEqual(status['value'], self.status.key)
|
||||
self.assertEqual(status['display_name'], self.status.label)
|
||||
|
||||
|
||||
class StockItemTest(StockAPITestCase):
|
||||
"""Series of API tests for the StockItem API."""
|
||||
|
||||
|
@ -615,7 +615,7 @@ function completeBuildOutputs(build_id, outputs, options={}) {
|
||||
method: 'POST',
|
||||
preFormContent: html,
|
||||
fields: {
|
||||
status: {},
|
||||
status_custom_key: {},
|
||||
location: {
|
||||
filters: {
|
||||
structural: false,
|
||||
@ -644,7 +644,7 @@ function completeBuildOutputs(build_id, outputs, options={}) {
|
||||
// Extract data elements from the form
|
||||
var data = {
|
||||
outputs: [],
|
||||
status: getFormFieldValue('status', {}, opts),
|
||||
status_custom_key: getFormFieldValue('status_custom_key', {}, opts),
|
||||
location: getFormFieldValue('location', {}, opts),
|
||||
notes: getFormFieldValue('notes', {}, opts),
|
||||
accept_incomplete_allocation: getFormFieldValue('accept_incomplete_allocation', {type: 'boolean'}, opts),
|
||||
@ -1153,7 +1153,7 @@ function loadBuildOrderAllocationTable(table, options={}) {
|
||||
if (row.build_detail) {
|
||||
html += `- <small>${row.build_detail.title}</small>`;
|
||||
|
||||
html += buildStatusDisplay(row.build_detail.status, {
|
||||
html += buildStatusDisplay(row.build_detail.status_custom_key, {
|
||||
classes: 'float-right',
|
||||
});
|
||||
}
|
||||
@ -1556,7 +1556,7 @@ function loadBuildOutputTable(build_info, options={}) {
|
||||
text += ` <small>({% trans "Batch" %}: ${row.batch})</small>`;
|
||||
}
|
||||
|
||||
text += stockStatusDisplay(row.status, {classes: 'float-right'});
|
||||
text += stockStatusDisplay(row.status_custom_key, {classes: 'float-right'});
|
||||
|
||||
return text;
|
||||
}
|
||||
@ -2362,7 +2362,7 @@ function loadBuildTable(table, options) {
|
||||
}
|
||||
},
|
||||
{
|
||||
field: 'status',
|
||||
field: 'status_custom_key',
|
||||
title: '{% trans "Status" %}',
|
||||
sortable: true,
|
||||
formatter: function(value) {
|
||||
|
@ -1761,7 +1761,7 @@ function loadPartPurchaseOrderTable(table, part_id, options={}) {
|
||||
var html = renderLink(order.reference, `/order/purchase-order/${order.pk}/`);
|
||||
|
||||
html += purchaseOrderStatusDisplay(
|
||||
order.status,
|
||||
order.status_custom_key,
|
||||
{
|
||||
classes: 'float-right',
|
||||
}
|
||||
|
@ -1788,12 +1788,12 @@ function loadPurchaseOrderTable(table, options) {
|
||||
}
|
||||
},
|
||||
{
|
||||
field: 'status',
|
||||
field: 'status_custom_key',
|
||||
title: '{% trans "Status" %}',
|
||||
switchable: true,
|
||||
sortable: true,
|
||||
formatter: function(value, row) {
|
||||
return purchaseOrderStatusDisplay(row.status);
|
||||
return purchaseOrderStatusDisplay(row.status_custom_key);
|
||||
}
|
||||
},
|
||||
{
|
||||
|
@ -326,10 +326,10 @@ function loadReturnOrderTable(table, options={}) {
|
||||
},
|
||||
{
|
||||
sortable: true,
|
||||
field: 'status',
|
||||
field: 'status_custom_key',
|
||||
title: '{% trans "Status" %}',
|
||||
formatter: function(value, row) {
|
||||
return returnOrderStatusDisplay(row.status);
|
||||
return returnOrderStatusDisplay(row.status_custom_key);
|
||||
}
|
||||
},
|
||||
{
|
||||
|
@ -851,10 +851,10 @@ function loadSalesOrderTable(table, options) {
|
||||
},
|
||||
{
|
||||
sortable: true,
|
||||
field: 'status',
|
||||
field: 'status_custom_key',
|
||||
title: '{% trans "Status" %}',
|
||||
formatter: function(value, row) {
|
||||
return salesOrderStatusDisplay(row.status);
|
||||
return salesOrderStatusDisplay(row.status_custom_key);
|
||||
}
|
||||
},
|
||||
{
|
||||
|
@ -380,7 +380,7 @@ function stockItemFields(options={}) {
|
||||
batch: {
|
||||
icon: 'fa-layer-group',
|
||||
},
|
||||
status: {},
|
||||
status_custom_key: {},
|
||||
expiry_date: {
|
||||
icon: 'fa-calendar-alt',
|
||||
},
|
||||
@ -698,7 +698,7 @@ function assignStockToCustomer(items, options={}) {
|
||||
|
||||
var thumbnail = thumbnailImage(part.thumbnail || part.image);
|
||||
|
||||
var status = stockStatusDisplay(item.status, {classes: 'float-right'});
|
||||
var status = stockStatusDisplay(item.status_custom_key, {classes: 'float-right'});
|
||||
|
||||
var quantity = '';
|
||||
|
||||
@ -879,7 +879,7 @@ function mergeStockItems(items, options={}) {
|
||||
quantity = `{% trans "Quantity" %}: ${item.quantity}`;
|
||||
}
|
||||
|
||||
quantity += stockStatusDisplay(item.status, {classes: 'float-right'});
|
||||
quantity += stockStatusDisplay(item.status_custom_key, {classes: 'float-right'});
|
||||
|
||||
let buttons = wrapButtons(
|
||||
makeIconButton(
|
||||
@ -1113,7 +1113,7 @@ function adjustStock(action, items, options={}) {
|
||||
|
||||
var thumb = thumbnailImage(item.part_detail.thumbnail || item.part_detail.image);
|
||||
|
||||
var status = stockStatusDisplay(item.status, {
|
||||
var status = stockStatusDisplay(item.status_custom_key, {
|
||||
classes: 'float-right'
|
||||
});
|
||||
|
||||
@ -1922,7 +1922,8 @@ function makeStockActions(table) {
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'status',
|
||||
|
||||
label: 'status_custom_key',
|
||||
icon: 'fa-info-circle icon-blue',
|
||||
title: '{% trans "Change stock status" %}',
|
||||
permission: 'stock.change',
|
||||
@ -2257,7 +2258,7 @@ function loadStockTable(table, options) {
|
||||
columns.push(col);
|
||||
|
||||
col = {
|
||||
field: 'status',
|
||||
field: 'status_custom_key',
|
||||
title: '{% trans "Status" %}',
|
||||
formatter: function(value) {
|
||||
return stockStatusDisplay(value);
|
||||
@ -3075,11 +3076,11 @@ function loadStockTrackingTable(table, options) {
|
||||
}
|
||||
|
||||
// Status information
|
||||
if (details.status) {
|
||||
if (details.status_custom_key) {
|
||||
html += `<tr><th>{% trans "Status" %}</td>`;
|
||||
|
||||
html += '<td>';
|
||||
html += stockStatusDisplay(details.status);
|
||||
html += stockStatusDisplay(details.status_custom_key);
|
||||
html += '</td></tr>';
|
||||
|
||||
}
|
||||
@ -3200,7 +3201,7 @@ function loadInstalledInTable(table, options) {
|
||||
}
|
||||
},
|
||||
{
|
||||
field: 'status',
|
||||
field: 'status_custom_key',
|
||||
title: '{% trans "Status" %}',
|
||||
formatter: function(value) {
|
||||
return stockStatusDisplay(value);
|
||||
@ -3401,7 +3402,7 @@ function setStockStatus(items, options={}) {
|
||||
method: 'POST',
|
||||
preFormContent: html,
|
||||
fields: {
|
||||
status: {},
|
||||
status_custom_key: {},
|
||||
note: {},
|
||||
},
|
||||
processBeforeUpload: function(data) {
|
||||
|
@ -345,6 +345,7 @@ class RuleSet(models.Model):
|
||||
'common_projectcode',
|
||||
'common_webhookendpoint',
|
||||
'common_webhookmessage',
|
||||
'common_inventreecustomuserstatemodel',
|
||||
'users_owner',
|
||||
# Third-party tables
|
||||
'error_report_error',
|
||||
|
@ -19,7 +19,7 @@ export function RenderBuildOrder(
|
||||
primary={instance.reference}
|
||||
secondary={instance.title}
|
||||
suffix={StatusRenderer({
|
||||
status: instance.status,
|
||||
status: instance.status_custom_key,
|
||||
type: ModelType.build
|
||||
})}
|
||||
image={instance.part_detail?.thumbnail || instance.part_detail?.image}
|
||||
@ -39,7 +39,7 @@ export function RenderBuildLine({
|
||||
primary={instance.part_detail.full_name}
|
||||
secondary={instance.quantity}
|
||||
suffix={StatusRenderer({
|
||||
status: instance.status,
|
||||
status: instance.status_custom_key,
|
||||
type: ModelType.build
|
||||
})}
|
||||
image={instance.part_detail.thumbnail || instance.part_detail.image}
|
||||
|
@ -15,6 +15,12 @@ export function RenderProjectCode({
|
||||
);
|
||||
}
|
||||
|
||||
export function RenderContentType({
|
||||
instance
|
||||
}: Readonly<InstanceRenderInterface>): ReactNode {
|
||||
return instance && <RenderInlineModel primary={instance.app_labeled_name} />;
|
||||
}
|
||||
|
||||
export function RenderImportSession({
|
||||
instance
|
||||
}: {
|
||||
|
@ -16,7 +16,11 @@ import {
|
||||
RenderManufacturerPart,
|
||||
RenderSupplierPart
|
||||
} from './Company';
|
||||
import { RenderImportSession, RenderProjectCode } from './Generic';
|
||||
import {
|
||||
RenderContentType,
|
||||
RenderImportSession,
|
||||
RenderProjectCode
|
||||
} from './Generic';
|
||||
import { ModelInformationDict } from './ModelType';
|
||||
import {
|
||||
RenderPurchaseOrder,
|
||||
@ -87,7 +91,8 @@ const RendererLookup: EnumDictionary<
|
||||
[ModelType.importsession]: RenderImportSession,
|
||||
[ModelType.reporttemplate]: RenderReportTemplate,
|
||||
[ModelType.labeltemplate]: RenderLabelTemplate,
|
||||
[ModelType.pluginconfig]: RenderPlugin
|
||||
[ModelType.pluginconfig]: RenderPlugin,
|
||||
[ModelType.contenttype]: RenderContentType
|
||||
};
|
||||
|
||||
export type RenderInstanceProps = {
|
||||
|
@ -241,6 +241,11 @@ export const ModelInformationDict: ModelDict = {
|
||||
url_overview: '/pluginconfig',
|
||||
url_detail: '/pluginconfig/:pk/',
|
||||
api_endpoint: ApiEndpoints.plugin_list
|
||||
},
|
||||
contenttype: {
|
||||
label: t`Content Type`,
|
||||
label_multiple: t`Content Types`,
|
||||
api_endpoint: ApiEndpoints.content_type_list
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -21,7 +21,7 @@ export function RenderPurchaseOrder(
|
||||
primary={instance.reference}
|
||||
secondary={instance.description}
|
||||
suffix={StatusRenderer({
|
||||
status: instance.status,
|
||||
status: instance.status_custom_key,
|
||||
type: ModelType.purchaseorder
|
||||
})}
|
||||
image={supplier.thumnbnail || supplier.image}
|
||||
@ -49,7 +49,7 @@ export function RenderReturnOrder(
|
||||
primary={instance.reference}
|
||||
secondary={instance.description}
|
||||
suffix={StatusRenderer({
|
||||
status: instance.status,
|
||||
status: instance.status_custom_key,
|
||||
type: ModelType.returnorder
|
||||
})}
|
||||
image={customer.thumnbnail || customer.image}
|
||||
@ -94,7 +94,7 @@ export function RenderSalesOrder(
|
||||
primary={instance.reference}
|
||||
secondary={instance.description}
|
||||
suffix={StatusRenderer({
|
||||
status: instance.status,
|
||||
status: instance.status_custom_key,
|
||||
type: ModelType.salesorder
|
||||
})}
|
||||
image={customer.thumnbnail || customer.image}
|
||||
|
@ -42,11 +42,13 @@ export enum ApiEndpoints {
|
||||
generate_barcode = 'barcode/generate/',
|
||||
news = 'news/',
|
||||
global_status = 'generic/status/',
|
||||
custom_state_list = 'generic/status/custom/',
|
||||
version = 'version/',
|
||||
license = 'license/',
|
||||
sso_providers = 'auth/providers/',
|
||||
group_list = 'user/group/',
|
||||
owner_list = 'user/owner/',
|
||||
content_type_list = 'contenttype/',
|
||||
icons = 'icons/',
|
||||
|
||||
// Data import endpoints
|
||||
|
@ -31,5 +31,6 @@ export enum ModelType {
|
||||
group = 'group',
|
||||
reporttemplate = 'reporttemplate',
|
||||
labeltemplate = 'labeltemplate',
|
||||
pluginconfig = 'pluginconfig'
|
||||
pluginconfig = 'pluginconfig',
|
||||
contenttype = 'contenttype'
|
||||
}
|
||||
|
@ -301,7 +301,7 @@ export function useCompleteBuildOutputsForm({
|
||||
};
|
||||
})
|
||||
},
|
||||
status: {},
|
||||
status_custom_key: {},
|
||||
location: {
|
||||
filters: {
|
||||
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 {
|
||||
return {
|
||||
name: {},
|
||||
|
@ -138,7 +138,7 @@ export function useStockFields({
|
||||
value: batchCode,
|
||||
onValueChange: (value) => setBatchCode(value)
|
||||
},
|
||||
status: {},
|
||||
status_custom_key: {},
|
||||
expiry_date: {
|
||||
// TODO: icon
|
||||
},
|
||||
@ -620,7 +620,7 @@ function stockChangeStatusFields(items: any[]): ApiFormFieldSet {
|
||||
},
|
||||
headers: [t`Part`, t`Location`, t`In Stock`, t`Actions`]
|
||||
},
|
||||
status: {},
|
||||
status_custom_key: {},
|
||||
note: {}
|
||||
};
|
||||
|
||||
|
@ -68,6 +68,10 @@ const ProjectCodeTable = Loadable(
|
||||
lazy(() => import('../../../../tables/settings/ProjectCodeTable'))
|
||||
);
|
||||
|
||||
const CustomStateTable = Loadable(
|
||||
lazy(() => import('../../../../tables/settings/CustomStateTable'))
|
||||
);
|
||||
|
||||
const CustomUnitsTable = Loadable(
|
||||
lazy(() => import('../../../../tables/settings/CustomUnitsTable'))
|
||||
);
|
||||
@ -135,6 +139,12 @@ export default function AdminCenter() {
|
||||
</Stack>
|
||||
)
|
||||
},
|
||||
{
|
||||
name: 'customstates',
|
||||
label: t`Custom States`,
|
||||
icon: <IconListDetails />,
|
||||
content: <CustomStateTable />
|
||||
},
|
||||
{
|
||||
name: 'customunits',
|
||||
label: t`Custom Units`,
|
||||
|
@ -526,7 +526,7 @@ export default function BuildDetail() {
|
||||
? []
|
||||
: [
|
||||
<StatusRenderer
|
||||
status={build.status}
|
||||
status={build.status_custom_key}
|
||||
type={ModelType.build}
|
||||
options={{ size: 'lg' }}
|
||||
/>
|
||||
|
@ -459,7 +459,7 @@ export default function PurchaseOrderDetail() {
|
||||
? []
|
||||
: [
|
||||
<StatusRenderer
|
||||
status={order.status}
|
||||
status={order.status_custom_key}
|
||||
type={ModelType.purchaseorder}
|
||||
options={{ size: 'lg' }}
|
||||
/>
|
||||
|
@ -300,7 +300,7 @@ export default function ReturnOrderDetail() {
|
||||
? []
|
||||
: [
|
||||
<StatusRenderer
|
||||
status={order.status}
|
||||
status={order.status_custom_key}
|
||||
type={ModelType.returnorder}
|
||||
options={{ size: 'lg' }}
|
||||
/>
|
||||
|
@ -498,7 +498,7 @@ export default function SalesOrderDetail() {
|
||||
? []
|
||||
: [
|
||||
<StatusRenderer
|
||||
status={order.status}
|
||||
status={order.status_custom_key}
|
||||
type={ModelType.salesorder}
|
||||
options={{ size: 'lg' }}
|
||||
key={order.pk}
|
||||
|
@ -601,7 +601,7 @@ export default function StockDetail() {
|
||||
key="batch"
|
||||
/>,
|
||||
<StatusRenderer
|
||||
status={stockitem.status}
|
||||
status={stockitem.status_custom_key}
|
||||
type={ModelType.stockitem}
|
||||
options={{ size: 'lg' }}
|
||||
key="status"
|
||||
|
@ -178,7 +178,7 @@ export function StatusColumn({
|
||||
sortable: sortable ?? true,
|
||||
title: title,
|
||||
hidden: hidden,
|
||||
render: TableStatusRenderer(model, accessor ?? 'status')
|
||||
render: TableStatusRenderer(model, accessor ?? 'status_custom_key')
|
||||
};
|
||||
}
|
||||
|
||||
|
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