2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-10-24 09:57:40 +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

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

View File

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

View File

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

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]