diff --git a/CHANGELOG.md b/CHANGELOG.md
index 582d447b33..3619673796 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Added `order_queryset` report helper function in [#10439](https://github.com/inventree/InvenTree/pull/10439)
+- Added much more detailed status information for machines to the API endpoint (including backend and frontend changes) in [#10381](https://github.com/inventree/InvenTree/pull/10381)
### Changed
diff --git a/docs/docs/plugins/machines/overview.md b/docs/docs/plugins/machines/overview.md
index 20c7e82856..6f778f9a3d 100644
--- a/docs/docs/plugins/machines/overview.md
+++ b/docs/docs/plugins/machines/overview.md
@@ -107,6 +107,7 @@ The machine type class gets instantiated for each machine on server startup and
- check_setting
- set_status
- set_status_text
+ - set_properties
### Drivers
@@ -152,6 +153,7 @@ class MyXyzAbcDriverPlugin(MachineDriverMixin, InvenTreePlugin):
- init_machine
- update_machine
- restart_machine
+ - ping_machines
- get_machines
- handle_error
@@ -224,3 +226,24 @@ class MyXYZDriver(ABCBaseDriver):
# ... do some init stuff here
machine.set_status_text("Paper missing")
```
+
+### Machine Properties
+
+Machine properties such as the device model, firmware version, and total pages printed can be displayed in the machine detail drawer to provide users with relevant device information.
+
+To achieve this, use the `machine.set_properties` function to set the desired properties. This can be combined with a periodic task, such as `ping_machines`, to keep the information up to date.
+
+```py
+from plugin.machine import MachineProperty
+
+class MyXYZDriver(ABCBaseDriver):
+ # ...
+ def ping_machines(self):
+ for machine in self.get_machines():
+ # ... fetch machine info
+ props: list[MachineProperty] = [
+ { 'key': 'Model', 'value': 'ABC' },
+ ]
+ machine.set_properties(props)
+
+```
diff --git a/docs/docs/settings/global.md b/docs/docs/settings/global.md
index 96c0944326..2efc2482e3 100644
--- a/docs/docs/settings/global.md
+++ b/docs/docs/settings/global.md
@@ -243,6 +243,12 @@ Refer to the [return order settings](../sales/return_order.md#return-order-setti
{{ globalsetting("ENABLE_PLUGINS_INTERFACE") }}
{{ globalsetting("ENABLE_PLUGINS_MAILS") }}
+### Machine Settings
+
+| Name | Description | Default | Units |
+| ---- | ----------- | ------- | ----- |
+{{ globalsetting("MACHINE_PING_ENABLED") }}
+
### Project Codes
Refer to the [project code settings](../concepts/project_codes.md).
diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py
index a4a9836586..94f6354fa1 100644
--- a/src/backend/InvenTree/InvenTree/api_version.py
+++ b/src/backend/InvenTree/InvenTree/api_version.py
@@ -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
diff --git a/src/backend/InvenTree/InvenTree/cache.py b/src/backend/InvenTree/InvenTree/cache.py
index e9a9ce8b83..fb3d050289 100644
--- a/src/backend/InvenTree/InvenTree/cache.py
+++ b/src/backend/InvenTree/InvenTree/cache.py
@@ -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)
},
},
},
diff --git a/src/backend/InvenTree/InvenTree/mixins.py b/src/backend/InvenTree/InvenTree/mixins.py
index d5512cabc0..43b5e0ab65 100644
--- a/src/backend/InvenTree/InvenTree/mixins.py
+++ b/src/backend/InvenTree/InvenTree/mixins.py
@@ -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)
diff --git a/src/backend/InvenTree/build/api.py b/src/backend/InvenTree/build/api.py
index 005e0ab593..a1448afac7 100644
--- a/src/backend/InvenTree/build/api.py
+++ b/src/backend/InvenTree/build/api.py
@@ -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.
diff --git a/src/backend/InvenTree/common/setting/system.py b/src/backend/InvenTree/common/setting/system.py
index 5cb4500187..305db33abf 100644
--- a/src/backend/InvenTree/common/setting/system.py
+++ b/src/backend/InvenTree/common/setting/system.py
@@ -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,
+ },
}
diff --git a/src/backend/InvenTree/company/api.py b/src/backend/InvenTree/company/api.py
index e5d23a5e9b..12e8a4c1e0 100644
--- a/src/backend/InvenTree/company/api.py
+++ b/src/backend/InvenTree/company/api.py
@@ -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
diff --git a/src/backend/InvenTree/generic/states/tests.py b/src/backend/InvenTree/generic/states/tests.py
index 943e88068d..30b45feb82 100644
--- a/src/backend/InvenTree/generic/states/tests.py
+++ b/src/backend/InvenTree/generic/states/tests.py
@@ -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')
diff --git a/src/backend/InvenTree/machine/__init__.py b/src/backend/InvenTree/machine/__init__.py
index 0085d9b4c3..0330cc90a6 100755
--- a/src/backend/InvenTree/machine/__init__.py
+++ b/src/backend/InvenTree/machine/__init__.py
@@ -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',
+]
diff --git a/src/backend/InvenTree/machine/machine_type.py b/src/backend/InvenTree/machine/machine_type.py
index 83c1801bba..13be37be38 100644
--- a/src/backend/InvenTree/machine/machine_type.py
+++ b/src/backend/InvenTree/machine/machine_type.py
@@ -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}
diff --git a/src/backend/InvenTree/machine/machine_types/label_printer.py b/src/backend/InvenTree/machine/machine_types/label_printer.py
index 180a431c86..cb3836e246 100644
--- a/src/backend/InvenTree/machine/machine_types/label_printer.py
+++ b/src/backend/InvenTree/machine/machine_types/label_printer.py
@@ -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
diff --git a/src/backend/InvenTree/machine/registry.py b/src/backend/InvenTree/machine/registry.py
index a509384a87..bc6480d2bb 100644
--- a/src/backend/InvenTree/machine/registry.py
+++ b/src/backend/InvenTree/machine/registry.py
@@ -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):
diff --git a/src/backend/InvenTree/machine/serializers.py b/src/backend/InvenTree/machine/serializers.py
index fcf75bd5dd..1105bd391c 100644
--- a/src/backend/InvenTree/machine/serializers.py
+++ b/src/backend/InvenTree/machine/serializers.py
@@ -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."""
diff --git a/src/backend/InvenTree/machine/tasks.py b/src/backend/InvenTree/machine/tasks.py
new file mode 100644
index 0000000000..764525cb28
--- /dev/null
+++ b/src/backend/InvenTree/machine/tasks.py
@@ -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()
diff --git a/src/backend/InvenTree/machine/tests.py b/src/backend/InvenTree/machine/tests.py
index e53cb2b7e1..99f9decc35 100755
--- a/src/backend/InvenTree/machine/tests.py
+++ b/src/backend/InvenTree/machine/tests.py
@@ -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)
diff --git a/src/backend/InvenTree/order/api.py b/src/backend/InvenTree/order/api.py
index 91f8c64b7d..b1dbbbfbfc 100644
--- a/src/backend/InvenTree/order/api.py
+++ b/src/backend/InvenTree/order/api.py
@@ -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)
diff --git a/src/backend/InvenTree/part/api.py b/src/backend/InvenTree/part/api.py
index 2eb53025e9..56bed73f85 100644
--- a/src/backend/InvenTree/part/api.py
+++ b/src/backend/InvenTree/part/api.py
@@ -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)
diff --git a/src/backend/InvenTree/plugin/machine/__init__.py b/src/backend/InvenTree/plugin/machine/__init__.py
index ec2750a58e..98a601d146 100644
--- a/src/backend/InvenTree/plugin/machine/__init__.py
+++ b/src/backend/InvenTree/plugin/machine/__init__.py
@@ -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',
diff --git a/src/backend/InvenTree/plugin/samples/machines/sample_printer.py b/src/backend/InvenTree/plugin/samples/machines/sample_printer.py
index c10f728256..3cfbf35673 100644
--- a/src/backend/InvenTree/plugin/samples/machines/sample_printer.py
+++ b/src/backend/InvenTree/plugin/samples/machines/sample_printer.py
@@ -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,
diff --git a/src/backend/InvenTree/stock/api.py b/src/backend/InvenTree/stock/api.py
index 76a16a6ab2..bc7ce58d91 100644
--- a/src/backend/InvenTree/stock/api.py
+++ b/src/backend/InvenTree/stock/api.py
@@ -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.
diff --git a/src/backend/InvenTree/users/api.py b/src/backend/InvenTree/users/api.py
index 39bfd595d4..71456fa9dc 100644
--- a/src/backend/InvenTree/users/api.py
+++ b/src/backend/InvenTree/users/api.py
@@ -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.
diff --git a/src/frontend/src/pages/Index/Settings/AdminCenter/MachineManagementPanel.tsx b/src/frontend/src/pages/Index/Settings/AdminCenter/MachineManagementPanel.tsx
index 4ec74b75b1..9c01d18e6e 100644
--- a/src/frontend/src/pages/Index/Settings/AdminCenter/MachineManagementPanel.tsx
+++ b/src/frontend/src/pages/Index/Settings/AdminCenter/MachineManagementPanel.tsx
@@ -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() {
+
+
+ {t`Machine Settings`}
+
+
+
+
+
);
}
diff --git a/src/frontend/src/tables/machine/MachineListTable.tsx b/src/frontend/src/tables/machine/MachineListTable.tsx
index bc15a60e62..7ded77a8cf 100644
--- a/src/frontend/src/tables/machine/MachineListTable.tsx
+++ b/src/frontend/src/tables/machine/MachineListTable.tsx
@@ -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({
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 (
<>
@@ -305,17 +328,22 @@ function MachineDrawer({
- {t`Machine Information`}
+ {t`General`}
-
+
{machine?.status_text}
@@ -392,7 +420,77 @@ function MachineDrawer({
-
+
+
+
+
+
+ {t`Properties`}
+
+
+
+
+ {groupedProperties.map(({ group, properties }) => (
+
+ {group && (
+
+ {group}
+
+ )}
+
+
+ {properties.map((prop) => (
+
+ {prop.key}
+
+ {prop.type === 'bool' ? (
+
+ ) : prop.type === 'progress' ? (
+
+
+
+ {prop.value} / {prop.max_progress}
+
+
+ ) : prop.type === 'int' ? (
+ {prop.value}
+ ) : prop.type === 'float' ? (
+
+ {formatDecimal(
+ Number.parseFloat(prop.value),
+ { digits: 4 }
+ )}
+
+ ) : (
+ {prop.value}
+ )}
+
+
+ ))}
+
+
+
+ ))}
+
+
{machine?.is_driver_available && (
@@ -404,13 +502,13 @@ function MachineDrawer({
{t`Machine Settings`}
-
+
-
+
)}
@@ -423,13 +521,13 @@ function MachineDrawer({
{t`Driver Settings`}
-
+
-
+
)}
@@ -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]