2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-10-25 10:27:39 +00:00

Merge branch 'master' of https://github.com/inventree/InvenTree into make-fields-filterable

This commit is contained in:
Matthias Mair
2025-10-06 00:20:32 +02:00
25 changed files with 388 additions and 180 deletions

View File

@@ -1,12 +1,15 @@
"""InvenTree API version information."""
# InvenTree API version
INVENTREE_API_VERSION = 400
INVENTREE_API_VERSION = 401
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
v401 -> 2025-10-04 : https://github.com/inventree/InvenTree/pull/10381
- Adds machine properties to machine API endpoints
v400 -> 2025-10-05 : https://github.com/inventree/InvenTree/pull/10486
- Adds return datatypes for admin/config and flags entpoints

View File

@@ -91,6 +91,13 @@ def get_cache_config(global_cache: bool) -> dict:
else:
redis_url = f'redis://{cache_host()}:{cache_port()}/0'
keepalive_options = {
'TCP_KEEPCNT': cache_setting('keepalive_count', 5, typecast=int),
'TCP_KEEPIDLE': cache_setting('keepalive_idle', 1, typecast=int),
'TCP_KEEPINTVL': cache_setting('keepalive_interval', 1, typecast=int),
'TCP_USER_TIMEOUT': cache_setting('user_timeout', 1000, typecast=int),
}
return {
'BACKEND': 'django_redis.cache.RedisCache',
'LOCATION': redis_url,
@@ -105,18 +112,11 @@ def get_cache_config(global_cache: bool) -> dict:
'tcp_keepalive', True, typecast=bool
),
'socket_keepalive_options': {
socket.TCP_KEEPCNT: cache_setting(
'keepalive_count', 5, typecast=int
),
socket.TCP_KEEPIDLE: cache_setting(
'keepalive_idle', 1, typecast=int
),
socket.TCP_KEEPINTVL: cache_setting(
'keepalive_interval', 1, typecast=int
),
socket.TCP_USER_TIMEOUT: cache_setting(
'user_timeout', 1000, typecast=int
),
# Only include options which are available on this platform
# e.g. MacOS does not have TCP_KEEPIDLE and TCP_USER_TIMEOUT
getattr(socket, key): value
for key, value in keepalive_options.items()
if hasattr(socket, key)
},
},
},

View File

@@ -228,3 +228,12 @@ class OutputOptionsMixin:
kwargs.update(self.output_options.format_params(params))
return super().get_serializer(*args, **kwargs)
class SerializerContextMixin:
"""Mixin to add context to serializer."""
def get_serializer(self, *args, **kwargs):
"""Add context to serializer."""
kwargs['context'] = self.get_serializer_context()
return super().get_serializer(*args, **kwargs)

View File

@@ -39,6 +39,7 @@ from InvenTree.mixins import (
ListCreateAPI,
OutputOptionsMixin,
RetrieveUpdateDestroyAPI,
SerializerContextMixin,
)
from users.models import Owner
@@ -387,14 +388,7 @@ class BuildList(DataExportViewMixin, BuildMixin, ListCreateAPI):
def get_serializer(self, *args, **kwargs):
"""Add extra context information to the endpoint serializer."""
try:
part_detail = str2bool(self.request.GET.get('part_detail', True))
except AttributeError:
part_detail = True
kwargs['part_detail'] = part_detail
kwargs['create'] = True
return super().get_serializer(*args, **kwargs)
@@ -527,17 +521,12 @@ class BuildLineFilter(FilterSet):
return queryset.exclude(flt)
class BuildLineMixin:
class BuildLineMixin(SerializerContextMixin):
"""Mixin class for BuildLine API endpoints."""
queryset = BuildLine.objects.all()
serializer_class = build.serializers.BuildLineSerializer
def get_serializer(self, *args, **kwargs):
"""Return the serializer instance for this endpoint."""
kwargs['context'] = self.get_serializer_context()
return super().get_serializer(*args, **kwargs)
def get_source_build(self) -> Build:
"""Return the source Build object for the BuildLine queryset.

View File

@@ -1130,4 +1130,12 @@ SYSTEM_SETTINGS: dict[str, InvenTreeSettingsKeyType] = {
'default': False,
'validator': bool,
},
'MACHINE_PING_ENABLED': {
'name': _('Enable Machine Ping'),
'description': _(
'Enable periodic ping task of registered machines to check their status'
),
'default': True,
'validator': bool,
},
}

View File

@@ -12,7 +12,12 @@ from data_exporter.mixins import DataExportViewMixin
from InvenTree.api import ListCreateDestroyAPIView, MetadataView
from InvenTree.fields import InvenTreeOutputOption, OutputConfiguration
from InvenTree.filters import SEARCH_ORDER_FILTER, SEARCH_ORDER_FILTER_ALIAS
from InvenTree.mixins import ListCreateAPI, OutputOptionsMixin, RetrieveUpdateDestroyAPI
from InvenTree.mixins import (
ListCreateAPI,
OutputOptionsMixin,
RetrieveUpdateDestroyAPI,
SerializerContextMixin,
)
from .models import (
Address,
@@ -170,7 +175,10 @@ class ManufacturerOutputOptions(OutputConfiguration):
class ManufacturerPartList(
DataExportViewMixin, OutputOptionsMixin, ListCreateDestroyAPIView
SerializerContextMixin,
DataExportViewMixin,
OutputOptionsMixin,
ListCreateDestroyAPIView,
):
"""API endpoint for list view of ManufacturerPart object.
@@ -181,13 +189,10 @@ class ManufacturerPartList(
queryset = ManufacturerPart.objects.all().prefetch_related(
'part', 'manufacturer', 'supplier_parts', 'tags'
)
serializer_class = ManufacturerPartSerializer
filterset_class = ManufacturerPartFilter
output_options = ManufacturerOutputOptions
filter_backends = SEARCH_ORDER_FILTER
search_fields = [
'manufacturer__name',
'description',
@@ -242,16 +247,16 @@ class ManufacturerPartParameterOptions(OutputConfiguration):
]
class ManufacturerPartParameterList(ListCreateDestroyAPIView, OutputOptionsMixin):
class ManufacturerPartParameterList(
SerializerContextMixin, ListCreateDestroyAPIView, OutputOptionsMixin
):
"""API endpoint for list view of ManufacturerPartParamater model."""
queryset = ManufacturerPartParameter.objects.all()
serializer_class = ManufacturerPartParameterSerializer
filterset_class = ManufacturerPartParameterFilter
output_options = ManufacturerPartParameterOptions
filter_backends = SEARCH_ORDER_FILTER
search_fields = ['name', 'value', 'units']
@@ -472,7 +477,7 @@ class SupplierPriceBreakOutputOptions(OutputConfiguration):
]
class SupplierPriceBreakList(OutputOptionsMixin, ListCreateAPI):
class SupplierPriceBreakList(SerializerContextMixin, OutputOptionsMixin, ListCreateAPI):
"""API endpoint for list view of SupplierPriceBreak object.
- GET: Retrieve list of SupplierPriceBreak objects

View File

@@ -253,7 +253,7 @@ class ApiTests(InvenTreeAPITestCase):
# MachineStatus model
machine_status = response.data['LabelPrinterStatus']
self.assertEqual(machine_status['status_class'], 'LabelPrinterStatus')
self.assertEqual(len(machine_status['values']), 6)
self.assertEqual(len(machine_status['values']), 8)
connected = machine_status['values']['CONNECTED']
self.assertEqual(connected['key'], 100)
self.assertEqual(connected['name'], 'CONNECTED')

View File

@@ -1,4 +1,15 @@
from machine.machine_type import BaseDriver, BaseMachineType, MachineStatus
from machine.machine_type import (
BaseDriver,
BaseMachineType,
MachineProperty,
MachineStatus,
)
from machine.registry import registry
__all__ = ['BaseDriver', 'BaseMachineType', 'MachineStatus', 'registry']
__all__ = [
'BaseDriver',
'BaseMachineType',
'MachineProperty',
'MachineStatus',
'registry',
]

View File

@@ -1,6 +1,6 @@
"""Base machine type/base driver."""
from typing import TYPE_CHECKING, Any, Literal, Union
from typing import TYPE_CHECKING, Any, Literal, TypedDict, Union
from generic.states import StatusCode
from InvenTree.helpers_mixin import (
@@ -48,6 +48,24 @@ class MachineStatus(StatusCode):
"""
class MachineProperty(TypedDict, total=False):
"""Type definition for machine properties.
Attributes:
key: Key of the property (required)
value: Value of the property (required)
group: Grouping of the property
type: Type of the property (one of 'str', 'bool', 'progress', 'int', 'float') default = 'str'
max_progress: Maximum value for progress type (required if type is 'progress')
"""
key: str
value: Union[str, bool, int, float]
group: str
type: Literal['str', 'bool', 'progress', 'int', 'float']
max_progress: Union[int, None]
class BaseDriver(
ClassValidationMixin,
ClassProviderMixin,
@@ -117,6 +135,12 @@ class BaseDriver(
machine: Machine instance
"""
def ping_machines(self):
"""Ping all machines using this driver to check if they are online.
This is called periodically by a background task if the setting 'MACHINE_PING_ENABLED' is active.
"""
def get_machines(self, **kwargs):
"""Return all machines using this driver (By default only initialized machines).
@@ -139,7 +163,8 @@ class BaseDriver(
Arguments:
error: Exception or string
"""
self.set_shared_state('errors', [*self.errors, error])
if error not in self.errors:
self.set_shared_state('errors', [*self.errors, error])
# --- state getters/setters
@property
@@ -321,7 +346,8 @@ class BaseMachineType(
Arguments:
error: Exception or string
"""
self.set_shared_state('errors', [*self.errors, error])
if error not in self.errors:
self.set_shared_state('errors', [*self.errors, error])
def reset_errors(self):
"""Helper function for resetting the error list for a machine."""
@@ -406,6 +432,26 @@ class BaseMachineType(
"""
self.set_shared_state('status_text', status_text)
def set_properties(self, properties: list[MachineProperty]):
"""Set the machine properties. This can be any arbitrary dict with model information, etc.
Arguments:
properties: The new properties dict to set
"""
for p in properties:
if 'type' not in p:
p['type'] = 'str'
if 'group' not in p:
p['group'] = ''
if p['type'] == 'progress' and 'max_progress' not in p:
p['max_progress'] = 100
if 'max_progress' not in p:
p['max_progress'] = None
self.set_shared_state('properties', properties)
# --- state getters/setters
@property
def initialized(self) -> bool:
@@ -440,3 +486,13 @@ class BaseMachineType(
def status_text(self) -> str:
"""Machine status text."""
return self.get_shared_state('status_text', '')
@property
def properties(self) -> list[MachineProperty]:
"""Return a dict of all relevant machine properties."""
return self.get_shared_state('properties', [])
@property
def properties_dict(self) -> dict[str, MachineProperty]:
"""Return a dict of all machine properties with key as dict key."""
return {prop['key']: prop for prop in self.properties}

View File

@@ -221,16 +221,21 @@ class LabelPrinterStatus(MachineStatus):
CONNECTED: The printer is connected and ready to print
UNKNOWN: The printer status is unknown (e.g. there is no active connection to the printer)
PRINTING: The printer is currently printing a label
WARNING: The printer is in an unknown warning condition
NO_MEDIA: The printer is out of media (e.g. the label spool is empty)
PAPER_JAM: The printer has a paper jam
DISCONNECTED: The driver cannot establish a connection to the printer
ERROR: The printer is in an unknown error condition
"""
CONNECTED = 100, _('Connected'), ColorEnum.success
UNKNOWN = 101, _('Unknown'), ColorEnum.secondary
PRINTING = 110, _('Printing'), ColorEnum.primary
WARNING = 200, _('Warning'), ColorEnum.warning
NO_MEDIA = 301, _('No media'), ColorEnum.warning
PAPER_JAM = 302, _('Paper jam'), ColorEnum.warning
DISCONNECTED = 400, _('Disconnected'), ColorEnum.danger
ERROR = 500, _('Error'), ColorEnum.danger
class LabelPrinterMachine(BaseMachineType):
@@ -250,7 +255,7 @@ class LabelPrinterMachine(BaseMachineType):
}
}
MACHINE_STATUS = LabelPrinterStatus
MACHINE_STATUS: type[LabelPrinterStatus] = LabelPrinterStatus
default_machine_status = LabelPrinterStatus.UNKNOWN

View File

@@ -125,7 +125,8 @@ class MachineRegistry(
def handle_error(self, error: Union[Exception, str]):
"""Helper function for capturing errors with the machine registry."""
self.set_shared_state('errors', [*self.errors, error])
if error not in self.errors:
self.set_shared_state('errors', [*self.errors, error])
@machine_registry_entrypoint(check_reload=False, check_ready=False)
def initialize(self, main: bool = False):

View File

@@ -10,6 +10,22 @@ from machine import registry
from machine.models import MachineConfig, MachineSetting
class MachinePropertySerializer(serializers.Serializer):
"""Serializer for a MachineProperty."""
class Meta:
"""Meta for serializer."""
fields = ['key', 'value', 'group', 'type', 'max_progress']
read_only_fields = fields
key = serializers.CharField()
value = serializers.CharField()
group = serializers.CharField()
type = serializers.CharField()
max_progress = serializers.IntegerField()
class MachineConfigSerializer(serializers.ModelSerializer):
"""Serializer for a MachineConfig."""
@@ -30,9 +46,10 @@ class MachineConfigSerializer(serializers.ModelSerializer):
'machine_errors',
'is_driver_available',
'restart_required',
'properties',
]
read_only_fields = ['machine_type', 'driver']
read_only_fields = ['machine_type', 'driver', 'properties']
initialized = serializers.SerializerMethodField('get_initialized')
status = serializers.SerializerMethodField('get_status')
@@ -41,6 +58,12 @@ class MachineConfigSerializer(serializers.ModelSerializer):
machine_errors = serializers.SerializerMethodField('get_errors')
is_driver_available = serializers.SerializerMethodField('get_is_driver_available')
restart_required = serializers.SerializerMethodField('get_restart_required')
properties = serializers.ListField(
child=MachinePropertySerializer(),
source='machine.properties',
read_only=True,
default=[],
)
def get_initialized(self, obj: MachineConfig) -> bool:
"""Serializer method for the initialized field."""

View File

@@ -0,0 +1,23 @@
"""Background task definitions for the 'machine' app."""
import structlog
from opentelemetry import trace
from common.settings import get_global_setting
from InvenTree.tasks import ScheduledTask, scheduled_task
from machine import registry
tracer = trace.get_tracer(__name__)
logger = structlog.get_logger('inventree')
@tracer.start_as_current_span('ping_machines')
@scheduled_task(ScheduledTask.MINUTES, 5)
def ping_machines():
"""Periodically ping all configured machines to check if they are online."""
if not get_global_setting('MACHINE_PING_ENABLED', True):
return
for driver in registry.get_drivers():
logger.debug("Pinging machines for driver '%s'", driver.SLUG)
driver.ping_machines()

View File

@@ -139,6 +139,27 @@ class TestDriverMachineInterface(TestMachineRegistryMixin, TestCase):
# Test machine restart hook
registry.restart_machine(machine.machine)
# Assert properties have been set and correctly initialized with default values
self.assertDictEqual(
machine.machine.properties_dict,
{
'Model': {
'key': 'Model',
'value': 'Sample Printer 3000',
'type': 'str',
'group': '',
'max_progress': None,
},
'Battery': {
'key': 'Battery',
'value': 42,
'type': 'progress',
'group': '',
'max_progress': 100,
},
},
)
# Test remove machine
self.assertEqual(len(registry.get_machines()), 2)
registry.remove_machine(machine)

View File

@@ -43,6 +43,7 @@ from InvenTree.mixins import (
ListCreateAPI,
OutputOptionsMixin,
RetrieveUpdateDestroyAPI,
SerializerContextMixin,
)
from order import models, serializers
from order.status_codes import (
@@ -57,22 +58,9 @@ from part.models import Part
from users.models import Owner
class GeneralExtraLineList(DataExportViewMixin):
class GeneralExtraLineList(SerializerContextMixin, DataExportViewMixin):
"""General template for ExtraLine API classes."""
def get_serializer(self, *args, **kwargs):
"""Return the serializer instance for this endpoint."""
try:
params = self.request.query_params3
kwargs['order_detail'] = str2bool(params.get('order_detail', False))
except AttributeError:
pass
kwargs['context'] = self.get_serializer_context()
return super().get_serializer(*args, **kwargs)
def get_queryset(self, *args, **kwargs):
"""Return the annotated queryset for this endpoint."""
queryset = super().get_queryset(*args, **kwargs)
@@ -358,18 +346,12 @@ class PurchaseOrderOutputOptions(OutputConfiguration):
OPTIONS = [InvenTreeOutputOption('supplier_detail')]
class PurchaseOrderMixin:
class PurchaseOrderMixin(SerializerContextMixin):
"""Mixin class for PurchaseOrder endpoints."""
queryset = models.PurchaseOrder.objects.all()
serializer_class = serializers.PurchaseOrderSerializer
def get_serializer(self, *args, **kwargs):
"""Return the serializer instance for this endpoint."""
# Ensure the request context is passed through
kwargs['context'] = self.get_serializer_context()
return super().get_serializer(*args, **kwargs)
def get_queryset(self, *args, **kwargs):
"""Return the annotated queryset for this endpoint."""
queryset = super().get_queryset(*args, **kwargs)
@@ -628,7 +610,7 @@ class PurchaseOrderLineItemOutputOptions(OutputConfiguration):
]
class PurchaseOrderLineItemMixin:
class PurchaseOrderLineItemMixin(SerializerContextMixin):
"""Mixin class for PurchaseOrderLineItem endpoints."""
queryset = models.PurchaseOrderLineItem.objects.all()
@@ -644,12 +626,6 @@ class PurchaseOrderLineItemMixin:
return queryset
def get_serializer(self, *args, **kwargs):
"""Return serializer instance for this endpoint."""
kwargs['context'] = self.get_serializer_context()
return super().get_serializer(*args, **kwargs)
def perform_update(self, serializer):
"""Override the perform_update method to auto-update pricing if required."""
super().perform_update(serializer)
@@ -831,19 +807,12 @@ class SalesOrderFilter(OrderFilter):
)
class SalesOrderMixin:
class SalesOrderMixin(SerializerContextMixin):
"""Mixin class for SalesOrder endpoints."""
queryset = models.SalesOrder.objects.all()
serializer_class = serializers.SalesOrderSerializer
def get_serializer(self, *args, **kwargs):
"""Return serializer instance for this endpoint."""
# Ensure the context is passed through to the serializer
kwargs['context'] = self.get_serializer_context()
return super().get_serializer(*args, **kwargs)
def get_queryset(self, *args, **kwargs):
"""Return annotated queryset for this endpoint."""
queryset = super().get_queryset(*args, **kwargs)
@@ -1022,18 +991,12 @@ class SalesOrderLineItemFilter(LineItemFilter):
return queryset.exclude(order__status__in=SalesOrderStatusGroups.OPEN)
class SalesOrderLineItemMixin:
class SalesOrderLineItemMixin(SerializerContextMixin):
"""Mixin class for SalesOrderLineItem endpoints."""
queryset = models.SalesOrderLineItem.objects.all()
serializer_class = serializers.SalesOrderLineItemSerializer
def get_serializer(self, *args, **kwargs):
"""Return serializer for this endpoint with extra data as requested."""
kwargs['context'] = self.get_serializer_context()
return super().get_serializer(*args, **kwargs)
def get_queryset(self, *args, **kwargs):
"""Return annotated queryset for this endpoint."""
queryset = super().get_queryset(*args, **kwargs)
@@ -1493,19 +1456,12 @@ class ReturnOrderFilter(OrderFilter):
)
class ReturnOrderMixin:
class ReturnOrderMixin(SerializerContextMixin):
"""Mixin class for ReturnOrder endpoints."""
queryset = models.ReturnOrder.objects.all()
serializer_class = serializers.ReturnOrderSerializer
def get_serializer(self, *args, **kwargs):
"""Return serializer instance for this endpoint."""
# Ensure the context is passed through to the serializer
kwargs['context'] = self.get_serializer_context()
return super().get_serializer(*args, **kwargs)
def get_queryset(self, *args, **kwargs):
"""Return annotated queryset for this endpoint."""
queryset = super().get_queryset(*args, **kwargs)
@@ -1650,18 +1606,12 @@ class ReturnOrderLineItemFilter(LineItemFilter):
return queryset.filter(received_date=None)
class ReturnOrderLineItemMixin:
class ReturnOrderLineItemMixin(SerializerContextMixin):
"""Mixin class for ReturnOrderLineItem endpoints."""
queryset = models.ReturnOrderLineItem.objects.all()
serializer_class = serializers.ReturnOrderLineItemSerializer
def get_serializer(self, *args, **kwargs):
"""Return serializer for this endpoint with extra data as requested."""
kwargs['context'] = self.get_serializer_context()
return super().get_serializer(*args, **kwargs)
def get_queryset(self, *args, **kwargs):
"""Return annotated queryset for this endpoint."""
queryset = super().get_queryset(*args, **kwargs)

View File

@@ -43,6 +43,7 @@ from InvenTree.mixins import (
RetrieveAPI,
RetrieveUpdateAPI,
RetrieveUpdateDestroyAPI,
SerializerContextMixin,
UpdateAPI,
)
from InvenTree.serializers import EmptySerializer
@@ -1014,7 +1015,7 @@ class PartFilter(FilterSet):
return queryset.filter(category__in=children)
class PartMixin:
class PartMixin(SerializerContextMixin):
"""Mixin class for Part API endpoints."""
serializer_class = part_serializers.PartSerializer
@@ -1037,9 +1038,6 @@ class PartMixin:
def get_serializer(self, *args, **kwargs):
"""Return a serializer instance for this endpoint."""
# Ensure the request context is passed through
kwargs['context'] = self.get_serializer_context()
# Indicate that we can create a new Part via this endpoint
kwargs['create'] = self.is_create
@@ -1053,7 +1051,6 @@ class PartMixin:
self.starred_parts = [
star.part for star in self.request.user.starred_parts.all()
]
kwargs['starred_parts'] = self.starred_parts
return super().get_serializer(*args, **kwargs)
@@ -1387,23 +1384,6 @@ class PartParameterAPIMixin:
return context
def get_serializer(self, *args, **kwargs):
"""Return the serializer instance for this API endpoint.
If requested, extra detail fields are annotated to the queryset:
- part_detail
- template_detail
"""
try:
kwargs['part_detail'] = str2bool(self.request.GET.get('part_detail', False))
kwargs['template_detail'] = str2bool(
self.request.GET.get('template_detail', True)
)
except AttributeError:
pass
return super().get_serializer(*args, **kwargs)
class PartParameterFilter(FilterSet):
"""Custom filters for the PartParameterList API endpoint."""
@@ -1614,19 +1594,12 @@ class BomFilter(FilterSet):
return queryset.filter(part.get_used_in_bom_item_filter())
class BomMixin:
class BomMixin(SerializerContextMixin):
"""Mixin class for BomItem API endpoints."""
serializer_class = part_serializers.BomItemSerializer
queryset = BomItem.objects.all()
def get_serializer(self, *args, **kwargs):
"""Return the serializer instance for this API endpoint."""
# Ensure the request context is passed through!
kwargs['context'] = self.get_serializer_context()
return super().get_serializer(*args, **kwargs)
def get_queryset(self, *args, **kwargs):
"""Return the queryset object for this endpoint."""
queryset = super().get_queryset(*args, **kwargs)

View File

@@ -1,9 +1,16 @@
from machine import BaseDriver, BaseMachineType, MachineStatus, registry
from machine import (
BaseDriver,
BaseMachineType,
MachineProperty,
MachineStatus,
registry,
)
from machine.registry import call_machine_function
__all__ = [
'BaseDriver',
'BaseMachineType',
'MachineProperty',
'MachineStatus',
'call_machine_function',
'registry',

View File

@@ -31,6 +31,10 @@ class SamplePrinterDriver(LabelPrinterBaseDriver):
def init_machine(self, machine: BaseMachineType) -> None:
"""Machine initialization hook."""
machine.set_properties([
{'key': 'Model', 'value': 'Sample Printer 3000'},
{'key': 'Battery', 'value': 42, 'type': 'progress'},
])
def print_label(
self,

View File

@@ -52,6 +52,7 @@ from InvenTree.mixins import (
OutputOptionsMixin,
RetrieveAPI,
RetrieveUpdateDestroyAPI,
SerializerContextMixin,
)
from order.models import PurchaseOrder, ReturnOrder, SalesOrder
from order.serializers import (
@@ -373,18 +374,12 @@ class StockLocationFilter(FilterSet):
return queryset
class StockLocationMixin:
class StockLocationMixin(SerializerContextMixin):
"""Mixin class for StockLocation API endpoints."""
queryset = StockLocation.objects.all()
serializer_class = StockSerializers.LocationSerializer
def get_serializer(self, *args, **kwargs):
"""Set context before returning serializer."""
kwargs['context'] = self.get_serializer_context()
return super().get_serializer(*args, **kwargs)
def get_queryset(self, *args, **kwargs):
"""Return annotated queryset for the StockLocationList endpoint."""
queryset = super().get_queryset(*args, **kwargs)
@@ -1012,7 +1007,7 @@ class StockFilter(FilterSet):
return queryset.filter(location__in=children)
class StockApiMixin:
class StockApiMixin(SerializerContextMixin):
"""Mixin class for StockItem API endpoints."""
serializer_class = StockSerializers.StockItemSerializer
@@ -1032,12 +1027,6 @@ class StockApiMixin:
return ctx
def get_serializer(self, *args, **kwargs):
"""Set context before returning serializer."""
kwargs['context'] = self.get_serializer_context()
return super().get_serializer(*args, **kwargs)
class StockOutputOptions(OutputConfiguration):
"""Output options for StockItem serializers."""
@@ -1317,7 +1306,7 @@ class StockItemSerialNumbers(RetrieveAPI):
serializer_class = StockSerializers.StockItemSerialNumbersSerializer
class StockItemTestResultMixin:
class StockItemTestResultMixin(SerializerContextMixin):
"""Mixin class for the StockItemTestResult API endpoints."""
queryset = StockItemTestResult.objects.all()
@@ -1329,12 +1318,6 @@ class StockItemTestResultMixin:
ctx['request'] = self.request
return ctx
def get_serializer(self, *args, **kwargs):
"""Set context before returning serializer."""
kwargs['context'] = self.get_serializer_context()
return super().get_serializer(*args, **kwargs)
class StockItemTestResultOutputOptions(OutputConfiguration):
"""Output options for StockItemTestResult endpoint."""
@@ -1487,7 +1470,9 @@ class StockTrackingOutputOptions(OutputConfiguration):
]
class StockTrackingList(DataExportViewMixin, OutputOptionsMixin, ListAPI):
class StockTrackingList(
SerializerContextMixin, DataExportViewMixin, OutputOptionsMixin, ListAPI
):
"""API endpoint for list view of StockItemTracking objects.
StockItemTracking objects are read-only
@@ -1500,12 +1485,6 @@ class StockTrackingList(DataExportViewMixin, OutputOptionsMixin, ListAPI):
serializer_class = StockSerializers.StockTrackingSerializer
output_options = StockTrackingOutputOptions
def get_serializer(self, *args, **kwargs):
"""Set context before returning serializer."""
kwargs['context'] = self.get_serializer_context()
return super().get_serializer(*args, **kwargs)
def get_delta_model_map(self) -> dict:
"""Return a mapping of delta models to their respective models and serializers.

View File

@@ -30,6 +30,7 @@ from InvenTree.mixins import (
RetrieveAPI,
RetrieveUpdateAPI,
RetrieveUpdateDestroyAPI,
SerializerContextMixin,
UpdateAPI,
)
from InvenTree.settings import FRONTEND_URL_BASE
@@ -278,7 +279,7 @@ class UserList(ListCreateAPI):
filterset_fields = ['is_staff', 'is_active', 'is_superuser']
class GroupMixin:
class GroupMixin(SerializerContextMixin):
"""Mixin for Group API endpoints to add permissions filter.
Permissions:
@@ -290,12 +291,6 @@ class GroupMixin:
serializer_class = GroupSerializer
permission_classes = [InvenTree.permissions.IsStaffOrReadOnlyScope]
def get_serializer(self, *args, **kwargs):
"""Return serializer instance for this endpoint."""
kwargs['context'] = self.get_serializer_context()
return super().get_serializer(*args, **kwargs)
def get_queryset(self):
"""Return queryset for this endpoint.

View File

@@ -17,6 +17,7 @@ import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { apiUrl } from '@lib/functions/Api';
import { api } from '../../../../App';
import { StylishText } from '../../../../components/items/StylishText';
import { GlobalSettingList } from '../../../../components/settings/SettingList';
import { MachineListTable } from '../../../../tables/machine/MachineListTable';
import {
MachineDriverTable,
@@ -116,6 +117,14 @@ export default function MachineManagementPanel() {
</Stack>
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value='settings'>
<Accordion.Control>
<StylishText size='lg'>{t`Machine Settings`}</StylishText>
</Accordion.Control>
<Accordion.Panel>
<GlobalSettingList keys={['MACHINE_PING_ENABLED']} />
</Accordion.Panel>
</Accordion.Item>
</Accordion>
);
}

View File

@@ -4,14 +4,16 @@ import {
Alert,
Badge,
Box,
Card,
Code,
Flex,
Group,
Indicator,
List,
LoadingOverlay,
Paper,
Progress,
Stack,
Table,
Text
} from '@mantine/core';
import { notifications } from '@mantine/notifications';
@@ -24,7 +26,7 @@ import { AddItemButton } from '@lib/components/AddItemButton';
import { YesNoButton } from '@lib/components/YesNoButton';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { apiUrl } from '@lib/functions/Api';
import { RowDeleteAction, RowEditAction } from '@lib/index';
import { RowDeleteAction, RowEditAction, formatDecimal } from '@lib/index';
import type { RowAction, TableColumn } from '@lib/types/Tables';
import type { InvenTreeTableProps } from '@lib/types/Tables';
import { Trans } from '@lingui/react/macro';
@@ -70,6 +72,13 @@ interface MachineI {
machine_errors: string[];
is_driver_available: boolean;
restart_required: boolean;
properties: {
key: string;
value: string;
group: string;
type: 'str' | 'progress' | 'bool' | 'int' | 'float';
max_progress: number;
}[];
}
function MachineStatusIndicator({ machine }: Readonly<{ machine: MachineI }>) {
@@ -183,10 +192,11 @@ function MachineDrawer({
const {
data: machine,
refetch,
isFetching: isMachineFetching
isLoading: isMachineFetching
} = useQuery<MachineI>({
enabled: true,
queryKey: ['machine-detail', machinePk],
refetchInterval: 5 * 1000,
queryFn: () =>
api
.get(apiUrl(ApiEndpoints.machine_list, machinePk))
@@ -251,6 +261,19 @@ function MachineDrawer({
}
});
const groupedProperties = useMemo(() => {
if (!machine?.properties) return [];
const groups: string[] = []; // track ordered list of groups
const groupMap: { [key: string]: typeof machine.properties } = {};
for (const prop of machine.properties) {
if (!groups.includes(prop.group)) groups.push(prop.group);
if (!groupMap[prop.group]) groupMap[prop.group] = [];
groupMap[prop.group].push(prop);
}
return groups.map((g) => ({ group: g, properties: groupMap[g] }));
}, [machine?.properties]);
return (
<>
<Stack gap='xs'>
@@ -305,17 +328,22 @@ function MachineDrawer({
<Accordion
multiple
defaultValue={['machine-info', 'machine-settings', 'driver-settings']}
defaultValue={[
'machine-info',
'machine-properties',
'machine-settings',
'driver-settings'
]}
>
<Accordion.Item
key={`machine-info-${machinePk}`}
value='machine-info'
>
<Accordion.Control>
<StylishText size='lg'>{t`Machine Information`}</StylishText>
<StylishText size='lg'>{t`General`}</StylishText>
</Accordion.Control>
<Accordion.Panel>
<Card withBorder>
<Paper withBorder p='md'>
<Stack gap='md'>
<Stack pos='relative' gap='xs'>
<LoadingOverlay
@@ -363,7 +391,7 @@ function MachineDrawer({
) : (
StatusRenderer({
status: `${machine?.status || -1}`,
type: `MachineStatus__${machine?.status_model}` as any
type: `${machine?.status_model}` as any
})
)}
<Text fz='sm'>{machine?.status_text}</Text>
@@ -392,7 +420,77 @@ function MachineDrawer({
</Group>
</Stack>
</Stack>
</Card>
</Paper>
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item
key={`machine-properties-${machinePk}`}
value='machine-properties'
>
<Accordion.Control>
<StylishText size='lg'>{t`Properties`}</StylishText>
</Accordion.Control>
<Accordion.Panel>
<Paper withBorder p='md'>
<Stack gap='sm'>
{groupedProperties.map(({ group, properties }) => (
<Stack key={group} gap={0}>
{group && (
<Text fz='sm' fw={700} mb={2}>
{group}
</Text>
)}
<Table
variant='vertical'
withTableBorder
verticalSpacing={4}
>
<Table.Tbody>
{properties.map((prop) => (
<Table.Tr key={prop.key}>
<Table.Th w={250}>{prop.key}</Table.Th>
<Table.Td>
{prop.type === 'bool' ? (
<YesNoButton
value={
`${prop.value}`.toLowerCase() === 'true'
}
/>
) : prop.type === 'progress' ? (
<Group>
<Progress
value={
(Number.parseInt(prop.value) /
prop.max_progress) *
100
}
flex={1}
/>
<Text>
{prop.value} / {prop.max_progress}
</Text>
</Group>
) : prop.type === 'int' ? (
<Text size='sm'>{prop.value}</Text>
) : prop.type === 'float' ? (
<Text size='sm'>
{formatDecimal(
Number.parseFloat(prop.value),
{ digits: 4 }
)}
</Text>
) : (
<Text size='sm'>{prop.value}</Text>
)}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Stack>
))}
</Stack>
</Paper>
</Accordion.Panel>
</Accordion.Item>
{machine?.is_driver_available && (
@@ -404,13 +502,13 @@ function MachineDrawer({
<StylishText size='lg'>{t`Machine Settings`}</StylishText>
</Accordion.Control>
<Accordion.Panel>
<Card withBorder>
<Paper withBorder p='xs'>
<MachineSettingList
machinePk={machinePk}
configType='M'
onChange={refreshAll}
/>
</Card>
</Paper>
</Accordion.Panel>
</Accordion.Item>
)}
@@ -423,13 +521,13 @@ function MachineDrawer({
<StylishText size='lg'>{t`Driver Settings`}</StylishText>
</Accordion.Control>
<Accordion.Panel>
<Card withBorder>
<Paper withBorder p='xs'>
<MachineSettingList
machinePk={machinePk}
configType='D'
onChange={refreshAll}
/>
</Card>
</Paper>
</Accordion.Panel>
</Accordion.Item>
)}
@@ -518,6 +616,15 @@ export function MachineListTable({
return renderer(record);
}
}
},
{
accessor: 'status_text',
sortable: false
},
{
accessor: 'machine_errors',
sortable: false,
render: (record) => record.machine_errors.join(', ')
}
],
[machineTypes]