mirror of
https://github.com/inventree/InvenTree.git
synced 2025-07-18 18:56:31 +00:00
Merge branch 'master' into matmair/issue6281
This commit is contained in:
.github/workflows
RELEASE.mdcontrib
docs/docs
src
backend
InvenTree
InvenTree
build
common
config_template.yamlgeneric
locale
ar
LC_MESSAGES
bg
LC_MESSAGES
cs
LC_MESSAGES
da
LC_MESSAGES
de
LC_MESSAGES
el
LC_MESSAGES
en
LC_MESSAGES
es
LC_MESSAGES
es_MX
LC_MESSAGES
et
LC_MESSAGES
fa
LC_MESSAGES
fi
LC_MESSAGES
fr
LC_MESSAGES
he
LC_MESSAGES
hi
LC_MESSAGES
hu
LC_MESSAGES
id
LC_MESSAGES
it
LC_MESSAGES
ja
LC_MESSAGES
ko
LC_MESSAGES
lt
LC_MESSAGES
lv
LC_MESSAGES
nl
LC_MESSAGES
no
LC_MESSAGES
pl
LC_MESSAGES
pt
LC_MESSAGES
pt_BR
LC_MESSAGES
ro
LC_MESSAGES
ru
LC_MESSAGES
sk
LC_MESSAGES
sl
LC_MESSAGES
sr
LC_MESSAGES
sv
LC_MESSAGES
th
LC_MESSAGES
tr
LC_MESSAGES
uk
LC_MESSAGES
vi
LC_MESSAGES
zh_Hans
LC_MESSAGES
zh_Hant
LC_MESSAGES
order
script
stock
frontend
src
components
contexts
defaults
forms
hooks
locales
ar
bg
cs
da
de
el
en
es
es_MX
et
fa
fi
fr
he
hi
hu
id
it
ja
ko
lt
lv
nl
no
pl
pt
pt_BR
ro
ru
sk
sl
sr
sv
th
tr
uk
vi
zh_Hans
zh_Hant
pages
build
company
part
purchasing
sales
stock
states
tables
tests
@@ -1,7 +1,7 @@
|
||||
"""InvenTree API version information."""
|
||||
|
||||
# InvenTree API version
|
||||
INVENTREE_API_VERSION = 296
|
||||
INVENTREE_API_VERSION = 297
|
||||
|
||||
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||
|
||||
@@ -11,6 +11,9 @@ v296 - 2024-12-22 : https://github.com/inventree/InvenTree/pull/6293
|
||||
- Removes a considerable amount of old auth endpoints
|
||||
- Introduces allauth based REST API
|
||||
|
||||
v297 - 2024-12-29 - https://github.com/inventree/InvenTree/pull/8438
|
||||
- Adjustments to the CustomUserState API endpoints and serializers
|
||||
|
||||
v296 - 2024-12-25 : https://github.com/inventree/InvenTree/pull/8732
|
||||
- Adjust default "part_detail" behaviour for StockItem API endpoints
|
||||
|
||||
|
@@ -419,20 +419,9 @@ def get_frontend_settings(debug=True):
|
||||
|
||||
# Set the base URL
|
||||
if 'base_url' not in settings:
|
||||
base_url = get_setting('INVENTREE_PUI_URL_BASE', 'pui_url_base', '')
|
||||
|
||||
if base_url:
|
||||
warnings.warn(
|
||||
"The 'INVENTREE_PUI_URL_BASE' key is deprecated. Please use 'INVENTREE_FRONTEND_URL_BASE' instead",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
else:
|
||||
base_url = get_setting(
|
||||
'INVENTREE_FRONTEND_URL_BASE', 'frontend_url_base', 'platform'
|
||||
)
|
||||
|
||||
settings['base_url'] = base_url
|
||||
settings['base_url'] = get_setting(
|
||||
'INVENTREE_FRONTEND_URL_BASE', 'frontend_url_base', 'platform'
|
||||
)
|
||||
|
||||
# Set the server list
|
||||
settings['server_list'] = settings.get('server_list', [])
|
||||
|
@@ -1032,6 +1032,12 @@ if SITE_URL:
|
||||
print(f"Invalid SITE_URL value: '{SITE_URL}'. InvenTree server cannot start.")
|
||||
sys.exit(-1)
|
||||
|
||||
else:
|
||||
logger.warning('No SITE_URL specified. Some features may not work correctly')
|
||||
logger.warning(
|
||||
'Specify a SITE_URL in the configuration file or via an environment variable'
|
||||
)
|
||||
|
||||
# Enable or disable multi-site framework
|
||||
SITE_MULTI = get_boolean_setting('INVENTREE_SITE_MULTI', 'site_multi', False)
|
||||
|
||||
@@ -1152,6 +1158,18 @@ SESSION_COOKIE_SECURE = (
|
||||
)
|
||||
)
|
||||
|
||||
# Ref: https://docs.djangoproject.com/en/4.2/ref/settings/#std-setting-SECURE_PROXY_SSL_HEADER
|
||||
if ssl_header := get_boolean_setting(
|
||||
'INVENTREE_USE_X_FORWARDED_PROTO', 'use_x_forwarded_proto', False
|
||||
):
|
||||
# The default header name is 'HTTP_X_FORWARDED_PROTO', but can be adjusted
|
||||
ssl_header_name = get_setting(
|
||||
'INVENTREE_X_FORWARDED_PROTO_NAME',
|
||||
'x_forwarded_proto_name',
|
||||
'HTTP_X_FORWARDED_PROTO',
|
||||
)
|
||||
SECURE_PROXY_SSL_HEADER = (ssl_header_name, 'https')
|
||||
|
||||
USE_X_FORWARDED_HOST = get_boolean_setting(
|
||||
'INVENTREE_USE_X_FORWARDED_HOST',
|
||||
config_key='use_x_forwarded_host',
|
||||
@@ -1368,7 +1386,7 @@ CUSTOMIZE = get_setting(
|
||||
|
||||
# Load settings for the frontend interface
|
||||
FRONTEND_SETTINGS = config.get_frontend_settings(debug=DEBUG)
|
||||
FRONTEND_URL_BASE = FRONTEND_SETTINGS.get('base_url', 'platform')
|
||||
FRONTEND_URL_BASE = FRONTEND_SETTINGS['base_url']
|
||||
|
||||
if DEBUG:
|
||||
logger.info('InvenTree running with DEBUG enabled')
|
||||
|
@@ -34,7 +34,17 @@ class BuildFilter(rest_filters.FilterSet):
|
||||
model = Build
|
||||
fields = ['sales_order']
|
||||
|
||||
status = rest_filters.NumberFilter(label='Status')
|
||||
status = rest_filters.NumberFilter(label=_('Order Status'), method='filter_status')
|
||||
|
||||
def filter_status(self, queryset, name, value):
|
||||
"""Filter by integer status code.
|
||||
|
||||
Note: Also account for the possibility of a custom status code
|
||||
"""
|
||||
q1 = Q(status=value, status_custom_key__isnull=True)
|
||||
q2 = Q(status_custom_key=value)
|
||||
|
||||
return queryset.filter(q1 | q2).distinct()
|
||||
|
||||
active = rest_filters.BooleanFilter(label='Build is active', method='filter_active')
|
||||
|
||||
|
@@ -4,6 +4,7 @@ import django.core.validators
|
||||
from django.db import migrations
|
||||
|
||||
import generic.states.fields
|
||||
import generic.states.validators
|
||||
import InvenTree.status_codes
|
||||
|
||||
|
||||
@@ -23,6 +24,11 @@ class Migration(migrations.Migration):
|
||||
help_text="Additional status information for this item",
|
||||
null=True,
|
||||
verbose_name="Custom status key",
|
||||
validators=[
|
||||
generic.states.validators.CustomStatusCodeValidator(
|
||||
status_class=InvenTree.status_codes.BuildStatus
|
||||
),
|
||||
]
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
@@ -32,7 +38,12 @@ class Migration(migrations.Migration):
|
||||
choices=InvenTree.status_codes.BuildStatus.items(),
|
||||
default=10,
|
||||
help_text="Build status code",
|
||||
validators=[django.core.validators.MinValueValidator(0)],
|
||||
validators=[
|
||||
django.core.validators.MinValueValidator(0),
|
||||
generic.states.validators.CustomStatusCodeValidator(
|
||||
status_class=InvenTree.status_codes.BuildStatus
|
||||
),
|
||||
],
|
||||
verbose_name="Build Status",
|
||||
),
|
||||
),
|
||||
|
@@ -43,7 +43,7 @@ from common.settings import (
|
||||
get_global_setting,
|
||||
prevent_build_output_complete_on_incompleted_tests,
|
||||
)
|
||||
from generic.states import StateTransitionMixin
|
||||
from generic.states import StateTransitionMixin, StatusCodeMixin
|
||||
from plugin.events import trigger_event
|
||||
from stock.status_codes import StockHistoryCode, StockStatus
|
||||
|
||||
@@ -59,6 +59,7 @@ class Build(
|
||||
InvenTree.models.PluginValidationMixin,
|
||||
InvenTree.models.ReferenceIndexingMixin,
|
||||
StateTransitionMixin,
|
||||
StatusCodeMixin,
|
||||
MPTTModel,
|
||||
):
|
||||
"""A Build object organises the creation of new StockItem objects from other existing StockItem objects.
|
||||
@@ -84,6 +85,8 @@ class Build(
|
||||
priority: Priority of the build
|
||||
"""
|
||||
|
||||
STATUS_CLASS = BuildStatus
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options for the BuildOrder model."""
|
||||
|
||||
@@ -319,6 +322,7 @@ class Build(
|
||||
verbose_name=_('Build Status'),
|
||||
default=BuildStatus.PENDING.value,
|
||||
choices=BuildStatus.items(),
|
||||
status_class=BuildStatus,
|
||||
validators=[MinValueValidator(0)],
|
||||
help_text=_('Build status code'),
|
||||
)
|
||||
|
@@ -574,7 +574,9 @@ class BuildOutputCompleteSerializer(serializers.Serializer):
|
||||
)
|
||||
|
||||
status_custom_key = serializers.ChoiceField(
|
||||
choices=StockStatus.items(), default=StockStatus.OK.value, label=_('Status')
|
||||
choices=StockStatus.items(custom=True),
|
||||
default=StockStatus.OK.value,
|
||||
label=_('Status'),
|
||||
)
|
||||
|
||||
accept_incomplete_allocation = serializers.BooleanField(
|
||||
|
32
src/backend/InvenTree/common/migrations/0034_alter_inventreecustomuserstatemodel_unique_together_and_more.py
Normal file
32
src/backend/InvenTree/common/migrations/0034_alter_inventreecustomuserstatemodel_unique_together_and_more.py
Normal file
@@ -0,0 +1,32 @@
|
||||
# Generated by Django 4.2.17 on 2024-12-27 09:15
|
||||
|
||||
import common.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('common', '0033_delete_colortheme'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterUniqueTogether(
|
||||
name='inventreecustomuserstatemodel',
|
||||
unique_together=set(),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='inventreecustomuserstatemodel',
|
||||
name='key',
|
||||
field=models.IntegerField(help_text='Numerical value that will be saved in the models database', verbose_name='Value'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='inventreecustomuserstatemodel',
|
||||
name='name',
|
||||
field=models.CharField(help_text='Name of the state', max_length=250, validators=[common.validators.validate_uppercase, common.validators.validate_variable_string], verbose_name='Name'),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='inventreecustomuserstatemodel',
|
||||
unique_together={('reference_status', 'name'), ('reference_status', 'key')},
|
||||
),
|
||||
]
|
@@ -51,7 +51,7 @@ import InvenTree.validators
|
||||
import users.models
|
||||
from common.setting.type import InvenTreeSettingsKeyType, SettingsKeyType
|
||||
from generic.states import ColorEnum
|
||||
from generic.states.custom import get_custom_classes, state_color_mappings
|
||||
from generic.states.custom import state_color_mappings
|
||||
from InvenTree.sanitizer import sanitize_svg
|
||||
|
||||
logger = logging.getLogger('inventree')
|
||||
@@ -1927,20 +1927,59 @@ class Attachment(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel
|
||||
|
||||
|
||||
class InvenTreeCustomUserStateModel(models.Model):
|
||||
"""Custom model to extends any registered state with extra custom, user defined states."""
|
||||
"""Custom model to extends any registered state with extra custom, user defined states.
|
||||
|
||||
Fields:
|
||||
reference_status: Status set that is extended with this custom state
|
||||
logical_key: State logical key that is equal to this custom state in business logic
|
||||
key: Numerical value that will be saved in the models database
|
||||
name: Name of the state (must be uppercase and a valid variable identifier)
|
||||
label: Label that will be displayed in the frontend (human readable)
|
||||
color: Color that will be displayed in the frontend
|
||||
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options for this mixin."""
|
||||
|
||||
verbose_name = _('Custom State')
|
||||
verbose_name_plural = _('Custom States')
|
||||
unique_together = [('reference_status', 'key'), ('reference_status', 'name')]
|
||||
|
||||
reference_status = models.CharField(
|
||||
max_length=250,
|
||||
verbose_name=_('Reference Status Set'),
|
||||
help_text=_('Status set that is extended with this custom state'),
|
||||
)
|
||||
|
||||
logical_key = models.IntegerField(
|
||||
verbose_name=_('Logical Key'),
|
||||
help_text=_(
|
||||
'State logical key that is equal to this custom state in business logic'
|
||||
),
|
||||
)
|
||||
|
||||
key = models.IntegerField(
|
||||
verbose_name=_('Key'),
|
||||
help_text=_('Value that will be saved in the models database'),
|
||||
verbose_name=_('Value'),
|
||||
help_text=_('Numerical value that will be saved in the models database'),
|
||||
)
|
||||
|
||||
name = models.CharField(
|
||||
max_length=250, verbose_name=_('Name'), help_text=_('Name of the state')
|
||||
max_length=250,
|
||||
verbose_name=_('Name'),
|
||||
help_text=_('Name of the state'),
|
||||
validators=[
|
||||
common.validators.validate_uppercase,
|
||||
common.validators.validate_variable_string,
|
||||
],
|
||||
)
|
||||
|
||||
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(),
|
||||
@@ -1948,12 +1987,7 @@ class InvenTreeCustomUserStateModel(models.Model):
|
||||
verbose_name=_('Color'),
|
||||
help_text=_('Color that will be displayed in the frontend'),
|
||||
)
|
||||
logical_key = models.IntegerField(
|
||||
verbose_name=_('Logical Key'),
|
||||
help_text=_(
|
||||
'State logical key that is equal to this custom state in business logic'
|
||||
),
|
||||
)
|
||||
|
||||
model = models.ForeignKey(
|
||||
ContentType,
|
||||
on_delete=models.SET_NULL,
|
||||
@@ -1962,18 +1996,6 @@ class InvenTreeCustomUserStateModel(models.Model):
|
||||
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."""
|
||||
@@ -1999,38 +2021,50 @@ class InvenTreeCustomUserStateModel(models.Model):
|
||||
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 == '':
|
||||
# Check against the reference status class
|
||||
status_class = self.get_status_class()
|
||||
|
||||
if not status_class:
|
||||
raise ValidationError({
|
||||
'reference_status': _('Reference status must be selected')
|
||||
'reference_status': _('Valid reference status class must be provided')
|
||||
})
|
||||
|
||||
# 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(): # noqa: SIM118
|
||||
if self.key in status_class.values():
|
||||
raise ValidationError({
|
||||
'key': _(
|
||||
'Key must be different from the logical keys of the reference status'
|
||||
)
|
||||
})
|
||||
if self.logical_key not in ref_set.keys(): # noqa: SIM118
|
||||
|
||||
if self.logical_key not in status_class.values():
|
||||
raise ValidationError({
|
||||
'logical_key': _(
|
||||
'Logical key must be in the logical keys of the reference status'
|
||||
)
|
||||
})
|
||||
|
||||
if self.name in status_class.names():
|
||||
raise ValidationError({
|
||||
'name': _(
|
||||
'Name must be different from the names of the reference status'
|
||||
)
|
||||
})
|
||||
|
||||
return super().clean()
|
||||
|
||||
def get_status_class(self):
|
||||
"""Return the appropriate status class for this custom state."""
|
||||
from generic.states import StatusCode
|
||||
from InvenTree.helpers import inheritors
|
||||
|
||||
if not self.reference_status:
|
||||
return None
|
||||
|
||||
# Return the first class that matches the reference status
|
||||
for cls in inheritors(StatusCode):
|
||||
if cls.__name__ == self.reference_status:
|
||||
return cls
|
||||
|
||||
|
||||
class SelectionList(InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel):
|
||||
"""Class which represents a list of selectable items for parameters.
|
||||
|
@@ -355,6 +355,7 @@ class CustomStateSerializer(DataImportExportSerializerMixin, InvenTreeModelSeria
|
||||
]
|
||||
|
||||
model_name = serializers.CharField(read_only=True, source='model.name')
|
||||
|
||||
reference_status = serializers.ChoiceField(
|
||||
choices=generic.states.custom.state_reference_mappings()
|
||||
)
|
||||
|
@@ -113,3 +113,17 @@ def validate_icon(name: Union[str, None]):
|
||||
return
|
||||
|
||||
common.icons.validate_icon(name)
|
||||
|
||||
|
||||
def validate_uppercase(value: str):
|
||||
"""Ensure that the provided value is uppercase."""
|
||||
value = str(value)
|
||||
|
||||
if value != value.upper():
|
||||
raise ValidationError(_('Value must be uppercase'))
|
||||
|
||||
|
||||
def validate_variable_string(value: str):
|
||||
"""The passed value must be a valid variable identifier string."""
|
||||
if not re.match(r'^[a-zA-Z_][a-zA-Z0-9_]*$', value):
|
||||
raise ValidationError(_('Value must be a valid variable identifier'))
|
||||
|
@@ -25,6 +25,9 @@ database:
|
||||
# HOST: Database host address (if required)
|
||||
# PORT: Database host port (if required)
|
||||
|
||||
# Base URL for the InvenTree server (or use the environment variable INVENTREE_SITE_URL)
|
||||
# site_url: 'http://localhost:8000'
|
||||
|
||||
# Set debug to False to run in production mode, or use the environment variable INVENTREE_DEBUG
|
||||
debug: False
|
||||
|
||||
@@ -45,8 +48,10 @@ log_level: WARNING
|
||||
# Configure if logs should be output in JSON format
|
||||
# Use environment variable INVENTREE_JSON_LOG
|
||||
json_log: False
|
||||
|
||||
# Enable database-level logging, or use the environment variable INVENTREE_DB_LOGGING
|
||||
db_logging: False
|
||||
|
||||
# Enable writing a log file, or use the environment variable INVENTREE_WRITE_LOG
|
||||
write_log: False
|
||||
|
||||
@@ -56,8 +61,6 @@ language: en-us
|
||||
# System time-zone (default is UTC). Reference: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
|
||||
timezone: UTC
|
||||
|
||||
# Base URL for the InvenTree server (or use the environment variable INVENTREE_SITE_URL)
|
||||
site_url: 'http://localhost:8000'
|
||||
|
||||
# Add new user on first startup by either adding values here or from a file
|
||||
#admin_user: admin
|
||||
@@ -114,14 +117,11 @@ allowed_hosts:
|
||||
# - 'http://localhost'
|
||||
# - 'http://*.localhost'
|
||||
|
||||
# Proxy forwarding settings
|
||||
# If InvenTree is running behind a proxy, you may need to configure these settings
|
||||
|
||||
# Override with the environment variable INVENTREE_USE_X_FORWARDED_HOST
|
||||
use_x_forwarded_host: false
|
||||
|
||||
# Override with the environment variable INVENTREE_USE_X_FORWARDED_PORT
|
||||
use_x_forwarded_port: false
|
||||
# Enable Proxy header passthrough
|
||||
# Override with the environment variable INVENTREE_USE_X_FORWARDED_<HEADER>
|
||||
# use_x_forwarded_host: true
|
||||
# use_x_forwarded_port: true
|
||||
# use_x_forwarded_proto: true
|
||||
|
||||
# Cookie settings (nominally the default settings should be fine)
|
||||
cookie:
|
||||
@@ -160,7 +160,6 @@ cache:
|
||||
host: 'inventree-cache'
|
||||
port: 6379
|
||||
|
||||
|
||||
# Login configuration
|
||||
login_confirm_days: 3
|
||||
login_attempts: 5
|
||||
@@ -206,10 +205,3 @@ ldap:
|
||||
# hide_password_reset: true
|
||||
# logo: img/custom_logo.png
|
||||
# splash: img/custom_splash.jpg
|
||||
# hide_pui_banner: true
|
||||
|
||||
# Set enabled frontends
|
||||
# Use the environment variable INVENTREE_CLASSIC_FRONTEND
|
||||
# classic_frontend: True
|
||||
# Use the environment variable INVENTREE_PLATFORM_FRONTEND
|
||||
# platform_frontend: True
|
||||
|
@@ -6,13 +6,14 @@ 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 ColorEnum, StatusCode
|
||||
from .states import ColorEnum, StatusCode, StatusCodeMixin
|
||||
from .transition import StateTransitionMixin, TransitionMethod, storage
|
||||
|
||||
__all__ = [
|
||||
'ColorEnum',
|
||||
'StateTransitionMixin',
|
||||
'StatusCode',
|
||||
'StatusCodeMixin',
|
||||
'TransitionMethod',
|
||||
'storage',
|
||||
]
|
||||
|
@@ -11,14 +11,13 @@ 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 .serializers import GenericStateClassSerializer
|
||||
from .states import StatusCode
|
||||
|
||||
|
||||
@@ -38,6 +37,7 @@ class StatusView(GenericAPIView):
|
||||
"""
|
||||
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
serializer_class = GenericStateClassSerializer
|
||||
|
||||
# Override status_class for implementing subclass
|
||||
MODEL_REF = 'statusmodel'
|
||||
@@ -56,7 +56,7 @@ class StatusView(GenericAPIView):
|
||||
@extend_schema(
|
||||
description='Retrieve information about a specific status code',
|
||||
responses={
|
||||
200: OpenApiResponse(description='Status code information'),
|
||||
200: GenericStateClassSerializer,
|
||||
400: OpenApiResponse(description='Invalid request'),
|
||||
},
|
||||
)
|
||||
@@ -70,9 +70,27 @@ class StatusView(GenericAPIView):
|
||||
if not issubclass(status_class, StatusCode):
|
||||
raise NotImplementedError('`status_class` not a valid StatusCode class')
|
||||
|
||||
data = {'class': status_class.__name__, 'values': status_class.dict()}
|
||||
data = {'status_class': status_class.__name__, 'values': status_class.dict()}
|
||||
|
||||
return Response(data)
|
||||
# Extend with custom values
|
||||
try:
|
||||
custom_values = status_class.custom_values()
|
||||
for item in custom_values:
|
||||
if item.name not in data['values']:
|
||||
data['values'][item.name] = {
|
||||
'color': item.color,
|
||||
'logical_key': item.logical_key,
|
||||
'key': item.key,
|
||||
'label': item.label,
|
||||
'name': item.name,
|
||||
'custom': True,
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
serializer = GenericStateClassSerializer(data, many=False)
|
||||
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
class AllStatusViews(StatusView):
|
||||
@@ -83,9 +101,32 @@ class AllStatusViews(StatusView):
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
"""Perform a GET request to learn information about status codes."""
|
||||
data = get_status_api_response()
|
||||
# Extend with MachineStatus classes
|
||||
data.update(get_status_api_response(MachineStatus, prefix=['MachineStatus']))
|
||||
from InvenTree.helpers import inheritors
|
||||
|
||||
data = {}
|
||||
|
||||
# Find all inherited status classes
|
||||
status_classes = inheritors(StatusCode)
|
||||
|
||||
for cls in status_classes:
|
||||
cls_data = {'status_class': cls.__name__, 'values': cls.dict()}
|
||||
|
||||
# Extend with custom values
|
||||
for item in cls.custom_values():
|
||||
label = str(item.name)
|
||||
if label not in cls_data['values']:
|
||||
print('custom value:', item)
|
||||
cls_data['values'][label] = {
|
||||
'color': item.color,
|
||||
'logical_key': item.logical_key,
|
||||
'key': item.key,
|
||||
'label': item.label,
|
||||
'name': item.name,
|
||||
'custom': True,
|
||||
}
|
||||
|
||||
data[cls.__name__] = GenericStateClassSerializer(cls_data, many=False).data
|
||||
|
||||
return Response(data)
|
||||
|
||||
|
||||
@@ -99,6 +140,7 @@ class CustomStateList(DataExportViewMixin, ListCreateAPI):
|
||||
filter_backends = SEARCH_ORDER_FILTER
|
||||
ordering_fields = ['key']
|
||||
search_fields = ['key', 'name', 'label', 'reference_status']
|
||||
filterset_fields = ['model', 'reference_status']
|
||||
|
||||
|
||||
class CustomStateDetail(RetrieveUpdateDestroyAPI):
|
||||
|
@@ -7,23 +7,7 @@ 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)
|
||||
}
|
||||
return {cls.tag(): cls for cls in inheritors(StatusCode)}
|
||||
|
||||
|
||||
def state_color_mappings():
|
||||
@@ -33,7 +17,7 @@ def state_color_mappings():
|
||||
|
||||
def state_reference_mappings():
|
||||
"""Return a list of custom user state references."""
|
||||
classes = get_custom_classes(include_custom=False)
|
||||
classes = inheritors(StatusCode)
|
||||
return [(a.__name__, a.__name__) for a in sorted(classes, key=lambda x: x.__name__)]
|
||||
|
||||
|
||||
@@ -42,48 +26,3 @@ def get_logical_value(value, model: str):
|
||||
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()
|
||||
|
@@ -90,6 +90,20 @@ class InvenTreeCustomStatusModelField(models.PositiveIntegerField):
|
||||
Models using this model field must also include the InvenTreeCustomStatusSerializerMixin in all serializers that create or update the value.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""Initialize the field."""
|
||||
from generic.states.validators import CustomStatusCodeValidator
|
||||
|
||||
self.status_class = kwargs.pop('status_class', None)
|
||||
|
||||
validators = kwargs.pop('validators', None) or []
|
||||
|
||||
if self.status_class:
|
||||
validators.append(CustomStatusCodeValidator(status_class=self.status_class))
|
||||
|
||||
kwargs['validators'] = validators
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def deconstruct(self):
|
||||
"""Deconstruct the field for migrations."""
|
||||
name, path, args, kwargs = super().deconstruct()
|
||||
@@ -109,14 +123,23 @@ class InvenTreeCustomStatusModelField(models.PositiveIntegerField):
|
||||
"""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."""
|
||||
from generic.states.validators import CustomStatusCodeValidator
|
||||
|
||||
validators = []
|
||||
|
||||
if self.status_class:
|
||||
validators.append(CustomStatusCodeValidator(status_class=self.status_class))
|
||||
|
||||
custom_key_field = ExtraInvenTreeCustomStatusModelField(
|
||||
default=None,
|
||||
verbose_name=_('Custom status key'),
|
||||
help_text=_('Additional status information for this item'),
|
||||
validators=validators,
|
||||
blank=True,
|
||||
null=True,
|
||||
)
|
||||
@@ -130,6 +153,10 @@ class ExtraInvenTreeCustomStatusModelField(models.PositiveIntegerField):
|
||||
This is not intended to be used directly, if you want to support custom states in your model use InvenTreeCustomStatusModelField.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""Initialize the field."""
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
class InvenTreeCustomStatusSerializerMixin:
|
||||
"""Mixin to ensure custom status fields are set.
|
||||
|
41
src/backend/InvenTree/generic/states/serializers.py
Normal file
41
src/backend/InvenTree/generic/states/serializers.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""Serializer classes for handling generic state information."""
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
|
||||
class GenericStateValueSerializer(serializers.Serializer):
|
||||
"""API serializer for generic state information."""
|
||||
|
||||
class Meta:
|
||||
"""Meta class for GenericStateValueSerializer."""
|
||||
|
||||
fields = ['key', 'logical_key', 'name', 'label', 'color', 'custom']
|
||||
|
||||
key = serializers.IntegerField(label=_('Key'), required=True)
|
||||
|
||||
logical_key = serializers.CharField(label=_('Logical Key'), required=False)
|
||||
|
||||
name = serializers.CharField(label=_('Name'), required=True)
|
||||
|
||||
label = serializers.CharField(label=_('Label'), required=True)
|
||||
|
||||
color = serializers.CharField(label=_('Color'), required=False)
|
||||
|
||||
custom = serializers.BooleanField(label=_('Custom'), required=False)
|
||||
|
||||
|
||||
class GenericStateClassSerializer(serializers.Serializer):
|
||||
"""API serializer for generic state class information."""
|
||||
|
||||
class Meta:
|
||||
"""Meta class for GenericStateClassSerializer."""
|
||||
|
||||
fields = ['status_class', 'values']
|
||||
|
||||
status_class = serializers.CharField(label=_('Class'), read_only=True)
|
||||
|
||||
values = serializers.DictField(
|
||||
child=GenericStateValueSerializer(), label=_('Values'), required=True
|
||||
)
|
@@ -1,9 +1,12 @@
|
||||
"""Generic implementation of status for InvenTree models."""
|
||||
|
||||
import enum
|
||||
import logging
|
||||
import re
|
||||
from enum import Enum
|
||||
|
||||
logger = logging.getLogger('inventree')
|
||||
|
||||
|
||||
class BaseEnum(enum.IntEnum): # noqa: PLW1641
|
||||
"""An `Enum` capabile of having its members have docstrings.
|
||||
@@ -102,10 +105,30 @@ class StatusCode(BaseEnum):
|
||||
return False
|
||||
return isinstance(value.value, int)
|
||||
|
||||
@classmethod
|
||||
def custom_queryset(cls):
|
||||
"""Return a queryset of all custom values for this status class."""
|
||||
from common.models import InvenTreeCustomUserStateModel
|
||||
|
||||
try:
|
||||
return InvenTreeCustomUserStateModel.objects.filter(
|
||||
reference_status=cls.__name__
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def custom_values(cls):
|
||||
"""Return all user-defined custom values for this status class."""
|
||||
if query := cls.custom_queryset():
|
||||
return list(query)
|
||||
return []
|
||||
|
||||
@classmethod
|
||||
def values(cls, key=None):
|
||||
"""Return a dict representation containing all required information."""
|
||||
elements = [itm for itm in cls if cls._is_element(itm.name)]
|
||||
|
||||
if key is None:
|
||||
return elements
|
||||
|
||||
@@ -138,19 +161,28 @@ class StatusCode(BaseEnum):
|
||||
return re.sub(r'(?<!^)(?=[A-Z])', '_', ref_name).lower()
|
||||
|
||||
@classmethod
|
||||
def items(cls):
|
||||
def items(cls, custom=False):
|
||||
"""All status code items."""
|
||||
return [(x.value, x.label) for x in cls.values()]
|
||||
data = [(x.value, x.label) for x in cls.values()]
|
||||
|
||||
if custom:
|
||||
try:
|
||||
for item in cls.custom_values():
|
||||
data.append((item.key, item.label))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return data
|
||||
|
||||
@classmethod
|
||||
def keys(cls):
|
||||
def keys(cls, custom=True):
|
||||
"""All status code keys."""
|
||||
return [x.value for x in cls.values()]
|
||||
return [el[0] for el in cls.items(custom=custom)]
|
||||
|
||||
@classmethod
|
||||
def labels(cls):
|
||||
def labels(cls, custom=True):
|
||||
"""All status code labels."""
|
||||
return [x.label for x in cls.values()]
|
||||
return [el[1] for el in cls.items(custom=custom)]
|
||||
|
||||
@classmethod
|
||||
def names(cls):
|
||||
@@ -174,23 +206,42 @@ class StatusCode(BaseEnum):
|
||||
return filtered.label
|
||||
|
||||
@classmethod
|
||||
def dict(cls, key=None):
|
||||
def dict(cls, key=None, custom=True):
|
||||
"""Return a dict representation containing all required information."""
|
||||
return {
|
||||
data = {
|
||||
x.name: {'color': x.color, 'key': x.value, 'label': x.label, 'name': x.name}
|
||||
for x in cls.values(key)
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def list(cls):
|
||||
"""Return the StatusCode options as a list of mapped key / value items."""
|
||||
return list(cls.dict().values())
|
||||
if custom:
|
||||
try:
|
||||
for item in cls.custom_values():
|
||||
if item.name not in data:
|
||||
data[item.name] = {
|
||||
'color': item.color,
|
||||
'key': item.key,
|
||||
'label': item.label,
|
||||
'name': item.name,
|
||||
'custom': True,
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return data
|
||||
|
||||
@classmethod
|
||||
def template_context(cls):
|
||||
def list(cls, custom=True):
|
||||
"""Return the StatusCode options as a list of mapped key / value items."""
|
||||
return list(cls.dict(custom=custom).values())
|
||||
|
||||
@classmethod
|
||||
def template_context(cls, custom=True):
|
||||
"""Return a dict representation containing all required information for templates."""
|
||||
ret = {x.name: x.value for x in cls.values()}
|
||||
ret['list'] = cls.list()
|
||||
data = cls.dict(custom=custom)
|
||||
|
||||
ret = {x['name']: x['key'] for x in data.values()}
|
||||
|
||||
ret['list'] = list(data.values())
|
||||
|
||||
return ret
|
||||
|
||||
@@ -205,3 +256,78 @@ class ColorEnum(Enum):
|
||||
warning = 'warning'
|
||||
info = 'info'
|
||||
dark = 'dark'
|
||||
|
||||
|
||||
class StatusCodeMixin:
|
||||
"""Mixin class which handles custom 'status' fields.
|
||||
|
||||
- Implements a 'set_stutus' method which can be used to set the status of an object
|
||||
- Implements a 'get_status' method which can be used to retrieve the status of an object
|
||||
|
||||
This mixin assumes that the implementing class has a 'status' field,
|
||||
which must be an instance of the InvenTreeCustomStatusModelField class.
|
||||
"""
|
||||
|
||||
STATUS_CLASS = None
|
||||
STATUS_FIELD = 'status'
|
||||
|
||||
@property
|
||||
def status_class(self):
|
||||
"""Return the status class associated with this model."""
|
||||
return self.STATUS_CLASS
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Custom save method for StatusCodeMixin.
|
||||
|
||||
- Ensure custom status code values are correctly updated
|
||||
"""
|
||||
if self.status_class:
|
||||
# Check that the current 'logical key' actually matches the current status code
|
||||
custom_values = self.status_class.custom_queryset().filter(
|
||||
logical_key=self.get_status(), key=self.get_custom_status()
|
||||
)
|
||||
|
||||
if not custom_values.exists():
|
||||
# No match - null out the custom value
|
||||
setattr(self, f'{self.STATUS_FIELD}_custom_key', None)
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def get_status(self) -> int:
|
||||
"""Return the status code for this object."""
|
||||
return getattr(self, self.STATUS_FIELD)
|
||||
|
||||
def get_custom_status(self) -> int:
|
||||
"""Return the custom status code for this object."""
|
||||
return getattr(self, f'{self.STATUS_FIELD}_custom_key', None)
|
||||
|
||||
def set_status(self, status: int) -> bool:
|
||||
"""Set the status code for this object."""
|
||||
if not self.status_class:
|
||||
raise NotImplementedError('Status class not defined')
|
||||
|
||||
base_values = self.status_class.values()
|
||||
custom_value_set = self.status_class.custom_values()
|
||||
|
||||
custom_field = f'{self.STATUS_FIELD}_custom_key'
|
||||
|
||||
result = False
|
||||
|
||||
if status in base_values:
|
||||
# Set the status to a 'base' value
|
||||
setattr(self, self.STATUS_FIELD, status)
|
||||
setattr(self, custom_field, None)
|
||||
result = True
|
||||
else:
|
||||
for item in custom_value_set:
|
||||
if item.key == status:
|
||||
# Set the status to a 'custom' value
|
||||
setattr(self, self.STATUS_FIELD, item.logical_key)
|
||||
setattr(self, custom_field, item.key)
|
||||
result = True
|
||||
break
|
||||
|
||||
if not result:
|
||||
logger.warning(f'Failed to set status {status} for class {self.__class__}')
|
||||
|
||||
return result
|
||||
|
@@ -174,10 +174,10 @@ class GeneralStateTest(InvenTreeTestCase):
|
||||
|
||||
# Correct call
|
||||
resp = view(rqst, **{StatusView.MODEL_REF: GeneralStatus})
|
||||
self.assertEqual(
|
||||
self.assertDictEqual(
|
||||
resp.data,
|
||||
{
|
||||
'class': 'GeneralStatus',
|
||||
'status_class': 'GeneralStatus',
|
||||
'values': {
|
||||
'COMPLETE': {
|
||||
'key': 30,
|
||||
@@ -228,11 +228,13 @@ class ApiTests(InvenTreeAPITestCase):
|
||||
def test_all_states(self):
|
||||
"""Test the API endpoint for listing all status models."""
|
||||
response = self.get(reverse('api-status-all'))
|
||||
|
||||
# 10 built-in state classes, plus the added GeneralState class
|
||||
self.assertEqual(len(response.data), 12)
|
||||
|
||||
# Test the BuildStatus model
|
||||
build_status = response.data['BuildStatus']
|
||||
self.assertEqual(build_status['class'], 'BuildStatus')
|
||||
self.assertEqual(build_status['status_class'], 'BuildStatus')
|
||||
self.assertEqual(len(build_status['values']), 5)
|
||||
pending = build_status['values']['PENDING']
|
||||
self.assertEqual(pending['key'], 10)
|
||||
@@ -241,7 +243,7 @@ class ApiTests(InvenTreeAPITestCase):
|
||||
|
||||
# Test the StockStatus model (static)
|
||||
stock_status = response.data['StockStatus']
|
||||
self.assertEqual(stock_status['class'], 'StockStatus')
|
||||
self.assertEqual(stock_status['status_class'], 'StockStatus')
|
||||
self.assertEqual(len(stock_status['values']), 8)
|
||||
in_stock = stock_status['values']['OK']
|
||||
self.assertEqual(in_stock['key'], 10)
|
||||
@@ -249,8 +251,8 @@ class ApiTests(InvenTreeAPITestCase):
|
||||
self.assertEqual(in_stock['label'], 'OK')
|
||||
|
||||
# MachineStatus model
|
||||
machine_status = response.data['MachineStatus__LabelPrinterStatus']
|
||||
self.assertEqual(machine_status['class'], 'LabelPrinterStatus')
|
||||
machine_status = response.data['LabelPrinterStatus']
|
||||
self.assertEqual(machine_status['status_class'], 'LabelPrinterStatus')
|
||||
self.assertEqual(len(machine_status['values']), 6)
|
||||
connected = machine_status['values']['CONNECTED']
|
||||
self.assertEqual(connected['key'], 100)
|
||||
@@ -267,10 +269,11 @@ class ApiTests(InvenTreeAPITestCase):
|
||||
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(stock_status_cstm['status_class'], 'StockStatus')
|
||||
self.assertEqual(len(stock_status_cstm['values']), 9)
|
||||
ok_advanced = stock_status_cstm['values']['OK']
|
||||
self.assertEqual(ok_advanced['key'], 10)
|
||||
|
21
src/backend/InvenTree/generic/states/validators.py
Normal file
21
src/backend/InvenTree/generic/states/validators.py
Normal file
@@ -0,0 +1,21 @@
|
||||
"""Validators for generic state management."""
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import BaseValidator
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
class CustomStatusCodeValidator(BaseValidator):
|
||||
"""Custom validator class for checking that a provided status code is valid."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""Initialize the validator."""
|
||||
self.status_class = kwargs.pop('status_class', None)
|
||||
super().__init__(limit_value=None, **kwargs)
|
||||
|
||||
def __call__(self, value):
|
||||
"""Check that the provided status code is valid."""
|
||||
if status_class := self.status_class:
|
||||
values = status_class.keys(custom=True)
|
||||
if value not in values:
|
||||
raise ValidationError(_('Invalid status code'))
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -82,8 +82,14 @@ class OrderFilter(rest_filters.FilterSet):
|
||||
status = rest_filters.NumberFilter(label=_('Order Status'), method='filter_status')
|
||||
|
||||
def filter_status(self, queryset, name, value):
|
||||
"""Filter by integer status code."""
|
||||
return queryset.filter(status=value)
|
||||
"""Filter by integer status code.
|
||||
|
||||
Note: Also account for the possibility of a custom status code.
|
||||
"""
|
||||
q1 = Q(status=value, status_custom_key__isnull=True)
|
||||
q2 = Q(status_custom_key=value)
|
||||
|
||||
return queryset.filter(q1 | q2).distinct()
|
||||
|
||||
# Exact match for reference
|
||||
reference = rest_filters.CharFilter(
|
||||
|
@@ -3,6 +3,7 @@
|
||||
from django.db import migrations
|
||||
|
||||
import generic.states.fields
|
||||
import generic.states.validators
|
||||
import InvenTree.status_codes
|
||||
|
||||
|
||||
@@ -22,6 +23,11 @@ class Migration(migrations.Migration):
|
||||
help_text="Additional status information for this item",
|
||||
null=True,
|
||||
verbose_name="Custom status key",
|
||||
validators=[
|
||||
generic.states.validators.CustomStatusCodeValidator(
|
||||
status_class=InvenTree.status_codes.PurchaseOrderStatus
|
||||
),
|
||||
]
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
@@ -33,6 +39,11 @@ class Migration(migrations.Migration):
|
||||
help_text="Additional status information for this item",
|
||||
null=True,
|
||||
verbose_name="Custom status key",
|
||||
validators=[
|
||||
generic.states.validators.CustomStatusCodeValidator(
|
||||
status_class=InvenTree.status_codes.ReturnOrderStatus
|
||||
),
|
||||
]
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
@@ -44,6 +55,11 @@ class Migration(migrations.Migration):
|
||||
help_text="Additional status information for this item",
|
||||
null=True,
|
||||
verbose_name="Custom status key",
|
||||
validators=[
|
||||
generic.states.validators.CustomStatusCodeValidator(
|
||||
status_class=InvenTree.status_codes.ReturnOrderLineStatus
|
||||
),
|
||||
]
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
@@ -55,6 +71,11 @@ class Migration(migrations.Migration):
|
||||
help_text="Additional status information for this item",
|
||||
null=True,
|
||||
verbose_name="Custom status key",
|
||||
validators=[
|
||||
generic.states.validators.CustomStatusCodeValidator(
|
||||
status_class=InvenTree.status_codes.SalesOrderStatus
|
||||
),
|
||||
]
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
@@ -65,6 +86,11 @@ class Migration(migrations.Migration):
|
||||
default=10,
|
||||
help_text="Purchase order status",
|
||||
verbose_name="Status",
|
||||
validators=[
|
||||
generic.states.validators.CustomStatusCodeValidator(
|
||||
status_class=InvenTree.status_codes.PurchaseOrderStatus
|
||||
),
|
||||
]
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
@@ -75,6 +101,11 @@ class Migration(migrations.Migration):
|
||||
default=10,
|
||||
help_text="Return order status",
|
||||
verbose_name="Status",
|
||||
validators=[
|
||||
generic.states.validators.CustomStatusCodeValidator(
|
||||
status_class=InvenTree.status_codes.ReturnOrderStatus
|
||||
),
|
||||
]
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
@@ -85,6 +116,11 @@ class Migration(migrations.Migration):
|
||||
default=10,
|
||||
help_text="Outcome for this line item",
|
||||
verbose_name="Outcome",
|
||||
validators=[
|
||||
generic.states.validators.CustomStatusCodeValidator(
|
||||
status_class=InvenTree.status_codes.ReturnOrderLineStatus
|
||||
),
|
||||
]
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
@@ -95,6 +131,11 @@ class Migration(migrations.Migration):
|
||||
default=10,
|
||||
help_text="Sales order status",
|
||||
verbose_name="Status",
|
||||
validators=[
|
||||
generic.states.validators.CustomStatusCodeValidator(
|
||||
status_class=InvenTree.status_codes.SalesOrderStatus
|
||||
),
|
||||
]
|
||||
),
|
||||
),
|
||||
]
|
||||
|
@@ -34,7 +34,7 @@ from common.currency import currency_code_default
|
||||
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 import StateTransitionMixin, StatusCodeMixin
|
||||
from generic.states.fields import InvenTreeCustomStatusModelField
|
||||
from InvenTree.exceptions import log_error
|
||||
from InvenTree.fields import (
|
||||
@@ -179,6 +179,7 @@ class TotalPriceMixin(models.Model):
|
||||
|
||||
|
||||
class Order(
|
||||
StatusCodeMixin,
|
||||
StateTransitionMixin,
|
||||
InvenTree.models.InvenTreeAttachmentMixin,
|
||||
InvenTree.models.InvenTreeBarcodeMixin,
|
||||
@@ -379,6 +380,7 @@ class PurchaseOrder(TotalPriceMixin, Order):
|
||||
|
||||
REFERENCE_PATTERN_SETTING = 'PURCHASEORDER_REFERENCE_PATTERN'
|
||||
REQUIRE_RESPONSIBLE_SETTING = 'PURCHASEORDER_REQUIRE_RESPONSIBLE'
|
||||
STATUS_CLASS = PurchaseOrderStatus
|
||||
|
||||
class Meta:
|
||||
"""Model meta options."""
|
||||
@@ -483,6 +485,7 @@ class PurchaseOrder(TotalPriceMixin, Order):
|
||||
status = InvenTreeCustomStatusModelField(
|
||||
default=PurchaseOrderStatus.PENDING.value,
|
||||
choices=PurchaseOrderStatus.items(),
|
||||
status_class=PurchaseOrderStatus,
|
||||
verbose_name=_('Status'),
|
||||
help_text=_('Purchase order status'),
|
||||
)
|
||||
@@ -912,6 +915,7 @@ class SalesOrder(TotalPriceMixin, Order):
|
||||
|
||||
REFERENCE_PATTERN_SETTING = 'SALESORDER_REFERENCE_PATTERN'
|
||||
REQUIRE_RESPONSIBLE_SETTING = 'SALESORDER_REQUIRE_RESPONSIBLE'
|
||||
STATUS_CLASS = SalesOrderStatus
|
||||
|
||||
class Meta:
|
||||
"""Model meta options."""
|
||||
@@ -1029,6 +1033,7 @@ class SalesOrder(TotalPriceMixin, Order):
|
||||
status = InvenTreeCustomStatusModelField(
|
||||
default=SalesOrderStatus.PENDING.value,
|
||||
choices=SalesOrderStatus.items(),
|
||||
status_class=SalesOrderStatus,
|
||||
verbose_name=_('Status'),
|
||||
help_text=_('Sales order status'),
|
||||
)
|
||||
@@ -2155,6 +2160,7 @@ class ReturnOrder(TotalPriceMixin, Order):
|
||||
|
||||
REFERENCE_PATTERN_SETTING = 'RETURNORDER_REFERENCE_PATTERN'
|
||||
REQUIRE_RESPONSIBLE_SETTING = 'RETURNORDER_REQUIRE_RESPONSIBLE'
|
||||
STATUS_CLASS = ReturnOrderStatus
|
||||
|
||||
class Meta:
|
||||
"""Model meta options."""
|
||||
@@ -2231,6 +2237,7 @@ class ReturnOrder(TotalPriceMixin, Order):
|
||||
status = InvenTreeCustomStatusModelField(
|
||||
default=ReturnOrderStatus.PENDING.value,
|
||||
choices=ReturnOrderStatus.items(),
|
||||
status_class=ReturnOrderStatus,
|
||||
verbose_name=_('Status'),
|
||||
help_text=_('Return order status'),
|
||||
)
|
||||
@@ -2446,9 +2453,12 @@ class ReturnOrder(TotalPriceMixin, Order):
|
||||
)
|
||||
|
||||
|
||||
class ReturnOrderLineItem(OrderLineItem):
|
||||
class ReturnOrderLineItem(StatusCodeMixin, OrderLineItem):
|
||||
"""Model for a single LineItem in a ReturnOrder."""
|
||||
|
||||
STATUS_CLASS = ReturnOrderLineStatus
|
||||
STATUS_FIELD = 'outcome'
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options for this model."""
|
||||
|
||||
@@ -2522,6 +2532,7 @@ class ReturnOrderLineItem(OrderLineItem):
|
||||
outcome = InvenTreeCustomStatusModelField(
|
||||
default=ReturnOrderLineStatus.PENDING.value,
|
||||
choices=ReturnOrderLineStatus.items(),
|
||||
status_class=ReturnOrderLineStatus,
|
||||
verbose_name=_('Outcome'),
|
||||
help_text=_('Outcome for this line item'),
|
||||
)
|
||||
|
@@ -769,7 +769,9 @@ class PurchaseOrderLineItemReceiveSerializer(serializers.Serializer):
|
||||
)
|
||||
|
||||
status = serializers.ChoiceField(
|
||||
choices=StockStatus.items(), default=StockStatus.OK.value, label=_('Status')
|
||||
choices=StockStatus.items(custom=True),
|
||||
default=StockStatus.OK.value,
|
||||
label=_('Status'),
|
||||
)
|
||||
|
||||
packaging = serializers.CharField(
|
||||
@@ -1935,7 +1937,7 @@ class ReturnOrderLineItemReceiveSerializer(serializers.Serializer):
|
||||
)
|
||||
|
||||
status = serializers.ChoiceField(
|
||||
choices=stock.status_codes.StockStatus.items(),
|
||||
choices=stock.status_codes.StockStatus.items(custom=True),
|
||||
default=None,
|
||||
label=_('Status'),
|
||||
help_text=_('Stock item status code'),
|
||||
|
@@ -1,80 +0,0 @@
|
||||
"""This script calculates translation coverage for various languages."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def calculate_coverage(filename):
|
||||
"""Calculate translation coverage for a .po file."""
|
||||
with open(filename, encoding='utf-8') as f:
|
||||
lines = f.readlines()
|
||||
|
||||
lines_count = 0
|
||||
lines_covered = 0
|
||||
lines_uncovered = 0
|
||||
|
||||
for line in lines:
|
||||
if line.startswith('msgid '):
|
||||
lines_count += 1
|
||||
|
||||
elif line.startswith('msgstr'):
|
||||
if line.startswith(('msgstr ""', "msgstr ''")):
|
||||
lines_uncovered += 1
|
||||
else:
|
||||
lines_covered += 1
|
||||
|
||||
# Return stats for the file
|
||||
return (lines_count, lines_covered, lines_uncovered)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
MY_DIR = os.path.dirname(os.path.realpath(__file__))
|
||||
LC_DIR = os.path.abspath(os.path.join(MY_DIR, '..', 'locale'))
|
||||
STAT_FILE = os.path.abspath(
|
||||
os.path.join(MY_DIR, '..', 'InvenTree/locale_stats.json')
|
||||
)
|
||||
|
||||
locales = {}
|
||||
locales_perc = {}
|
||||
|
||||
verbose = '-v' in sys.argv
|
||||
|
||||
for locale in os.listdir(LC_DIR):
|
||||
path = os.path.join(LC_DIR, locale)
|
||||
if os.path.exists(path) and os.path.isdir(path):
|
||||
locale_file = os.path.join(path, 'LC_MESSAGES', 'django.po')
|
||||
|
||||
if os.path.exists(locale_file) and os.path.isfile(locale_file):
|
||||
locales[locale] = locale_file
|
||||
|
||||
if verbose:
|
||||
print('-' * 16)
|
||||
|
||||
percentages = []
|
||||
|
||||
for locale in locales:
|
||||
locale_file = locales[locale]
|
||||
stats = calculate_coverage(locale_file)
|
||||
|
||||
(total, covered, uncovered) = stats
|
||||
|
||||
percentage = int(covered / total * 100) if total > 0 else 0
|
||||
|
||||
if verbose:
|
||||
print(f'| {locale.ljust(4, " ")} : {str(percentage).rjust(4, " ")}% |')
|
||||
|
||||
locales_perc[locale] = percentage
|
||||
|
||||
percentages.append(percentage)
|
||||
|
||||
if verbose:
|
||||
print('-' * 16)
|
||||
|
||||
# write locale stats
|
||||
with open(STAT_FILE, 'w', encoding='utf-8') as target:
|
||||
json.dump(locales_perc, target)
|
||||
|
||||
avg = int(sum(percentages) / len(percentages)) if len(percentages) > 0 else 0
|
||||
|
||||
print(f'InvenTree translation coverage: {avg}%')
|
@@ -571,8 +571,14 @@ class StockFilter(rest_filters.FilterSet):
|
||||
status = rest_filters.NumberFilter(label=_('Status Code'), method='filter_status')
|
||||
|
||||
def filter_status(self, queryset, name, value):
|
||||
"""Filter by integer status code."""
|
||||
return queryset.filter(status=value)
|
||||
"""Filter by integer status code.
|
||||
|
||||
Note: Also account for the possibility of a custom status code.
|
||||
"""
|
||||
q1 = Q(status=value, status_custom_key__isnull=True)
|
||||
q2 = Q(status_custom_key=value)
|
||||
|
||||
return queryset.filter(q1 | q2).distinct()
|
||||
|
||||
allocated = rest_filters.BooleanFilter(
|
||||
label='Is Allocated', method='filter_allocated'
|
||||
|
@@ -5,6 +5,7 @@ from django.db import migrations
|
||||
|
||||
import generic.states
|
||||
import generic.states.fields
|
||||
import generic.states.validators
|
||||
import InvenTree.status_codes
|
||||
|
||||
|
||||
@@ -24,6 +25,11 @@ class Migration(migrations.Migration):
|
||||
help_text="Additional status information for this item",
|
||||
null=True,
|
||||
verbose_name="Custom status key",
|
||||
validators=[
|
||||
generic.states.validators.CustomStatusCodeValidator(
|
||||
status_class=InvenTree.status_codes.StockStatus
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
@@ -32,7 +38,12 @@ class Migration(migrations.Migration):
|
||||
field=generic.states.fields.InvenTreeCustomStatusModelField(
|
||||
choices=InvenTree.status_codes.StockStatus.items(),
|
||||
default=10,
|
||||
validators=[django.core.validators.MinValueValidator(0)],
|
||||
validators=[
|
||||
django.core.validators.MinValueValidator(0),
|
||||
generic.states.validators.CustomStatusCodeValidator(
|
||||
status_class=InvenTree.status_codes.StockStatus
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
]
|
||||
|
@@ -37,6 +37,7 @@ import stock.tasks
|
||||
from common.icons import validate_icon
|
||||
from common.settings import get_global_setting
|
||||
from company import models as CompanyModels
|
||||
from generic.states import StatusCodeMixin
|
||||
from generic.states.fields import InvenTreeCustomStatusModelField
|
||||
from InvenTree.fields import InvenTreeModelMoneyField, InvenTreeURLField
|
||||
from InvenTree.status_codes import (
|
||||
@@ -340,6 +341,7 @@ class StockItem(
|
||||
InvenTree.models.InvenTreeAttachmentMixin,
|
||||
InvenTree.models.InvenTreeBarcodeMixin,
|
||||
InvenTree.models.InvenTreeNotesMixin,
|
||||
StatusCodeMixin,
|
||||
report.mixins.InvenTreeReportMixin,
|
||||
InvenTree.models.MetadataMixin,
|
||||
InvenTree.models.PluginValidationMixin,
|
||||
@@ -373,6 +375,8 @@ class StockItem(
|
||||
packaging: Description of how the StockItem is packaged (e.g. "reel", "loose", "tape" etc)
|
||||
"""
|
||||
|
||||
STATUS_CLASS = StockStatus
|
||||
|
||||
class Meta:
|
||||
"""Model meta options."""
|
||||
|
||||
@@ -1020,6 +1024,7 @@ class StockItem(
|
||||
|
||||
status = InvenTreeCustomStatusModelField(
|
||||
default=StockStatus.OK.value,
|
||||
status_class=StockStatus,
|
||||
choices=StockStatus.items(),
|
||||
validators=[MinValueValidator(0)],
|
||||
)
|
||||
@@ -2137,6 +2142,12 @@ class StockItem(
|
||||
else:
|
||||
tracking_info['location'] = location.pk
|
||||
|
||||
status = kwargs.pop('status', None) or kwargs.pop('status_custom_key', None)
|
||||
|
||||
if status and status != self.status:
|
||||
self.set_status(status)
|
||||
tracking_info['status'] = status
|
||||
|
||||
# Optional fields which can be supplied in a 'move' call
|
||||
for field in StockItem.optional_transfer_fields():
|
||||
if field in kwargs:
|
||||
@@ -2214,8 +2225,16 @@ class StockItem(
|
||||
if count < 0:
|
||||
return False
|
||||
|
||||
tracking_info = {}
|
||||
|
||||
status = kwargs.pop('status', None) or kwargs.pop('status_custom_key', None)
|
||||
|
||||
if status and status != self.status:
|
||||
self.set_status(status)
|
||||
tracking_info['status'] = status
|
||||
|
||||
if self.updateQuantity(count):
|
||||
tracking_info = {'quantity': float(count)}
|
||||
tracking_info['quantity'] = float(count)
|
||||
|
||||
self.stocktake_date = InvenTree.helpers.current_date()
|
||||
self.stocktake_user = user
|
||||
@@ -2269,8 +2288,17 @@ class StockItem(
|
||||
if quantity <= 0:
|
||||
return False
|
||||
|
||||
tracking_info = {}
|
||||
|
||||
status = kwargs.pop('status', None) or kwargs.pop('status_custom_key', None)
|
||||
|
||||
if status and status != self.status:
|
||||
self.set_status(status)
|
||||
tracking_info['status'] = status
|
||||
|
||||
if self.updateQuantity(self.quantity + quantity):
|
||||
tracking_info = {'added': float(quantity), 'quantity': float(self.quantity)}
|
||||
tracking_info['added'] = float(quantity)
|
||||
tracking_info['quantity'] = float(self.quantity)
|
||||
|
||||
# Optional fields which can be supplied in a 'stocktake' call
|
||||
for field in StockItem.optional_transfer_fields():
|
||||
@@ -2314,8 +2342,17 @@ class StockItem(
|
||||
if quantity <= 0:
|
||||
return False
|
||||
|
||||
deltas = {}
|
||||
|
||||
status = kwargs.pop('status', None) or kwargs.pop('status_custom_key', None)
|
||||
|
||||
if status and status != self.status:
|
||||
self.set_status(status)
|
||||
deltas['status'] = status
|
||||
|
||||
if self.updateQuantity(self.quantity - quantity):
|
||||
deltas = {'removed': float(quantity), 'quantity': float(self.quantity)}
|
||||
deltas['removed'] = float(quantity)
|
||||
deltas['quantity'] = float(self.quantity)
|
||||
|
||||
if location := kwargs.get('location'):
|
||||
deltas['location'] = location.pk
|
||||
|
@@ -980,7 +980,7 @@ class ReturnStockItemSerializer(serializers.Serializer):
|
||||
)
|
||||
|
||||
status = serializers.ChoiceField(
|
||||
choices=stock.status_codes.StockStatus.items(),
|
||||
choices=stock.status_codes.StockStatus.items(custom=True),
|
||||
default=None,
|
||||
label=_('Status'),
|
||||
help_text=_('Stock item status code'),
|
||||
@@ -996,7 +996,7 @@ class ReturnStockItemSerializer(serializers.Serializer):
|
||||
)
|
||||
|
||||
def save(self):
|
||||
"""Save the serialzier to return the item into stock."""
|
||||
"""Save the serializer to return the item into stock."""
|
||||
item = self.context['item']
|
||||
request = self.context['request']
|
||||
|
||||
@@ -1037,7 +1037,7 @@ class StockChangeStatusSerializer(serializers.Serializer):
|
||||
return items
|
||||
|
||||
status = serializers.ChoiceField(
|
||||
choices=stock.status_codes.StockStatus.items(),
|
||||
choices=stock.status_codes.StockStatus.items(custom=True),
|
||||
default=stock.status_codes.StockStatus.OK.value,
|
||||
label=_('Status'),
|
||||
)
|
||||
@@ -1533,11 +1533,11 @@ def stock_item_adjust_status_options():
|
||||
|
||||
In particular, include a Null option for the status field.
|
||||
"""
|
||||
return [(None, _('No Change')), *stock.status_codes.StockStatus.items()]
|
||||
return [(None, _('No Change')), *stock.status_codes.StockStatus.items(custom=True)]
|
||||
|
||||
|
||||
class StockAdjustmentItemSerializer(serializers.Serializer):
|
||||
"""Serializer for a single StockItem within a stock adjument request.
|
||||
"""Serializer for a single StockItem within a stock adjustment request.
|
||||
|
||||
Required Fields:
|
||||
- item: StockItem object
|
||||
|
Reference in New Issue
Block a user