2
0
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:
Matthias Mair
2025-01-02 09:25:47 +01:00
committed by GitHub
147 changed files with 59274 additions and 53019 deletions
.github/workflows
RELEASE.md
contrib
container
packager.io
docs/docs
src
backend
InvenTree
InvenTree
build
common
config_template.yaml
generic
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
tasks.py

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

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

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

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