From 66a488b6a25594b9d0c3fdfa5b94c2e82d60e7d6 Mon Sep 17 00:00:00 2001 From: Lukas <76838159+wolflu05@users.noreply.github.com> Date: Mon, 6 Oct 2025 00:01:53 +0200 Subject: [PATCH 1/2] Machine properties and periodic ping (#10381) * add machine properties * remove non working polyfill * add periodic task * add tests and docs * fix ping task * add int and float type * Update api_version.py --------- Co-authored-by: Matthias Mair --- CHANGELOG.md | 1 + docs/docs/plugins/machines/overview.md | 23 +++ docs/docs/settings/global.md | 6 + .../InvenTree/InvenTree/api_version.py | 5 +- src/backend/InvenTree/InvenTree/cache.py | 24 ++-- .../InvenTree/common/setting/system.py | 8 ++ src/backend/InvenTree/generic/states/tests.py | 2 +- src/backend/InvenTree/machine/__init__.py | 15 +- src/backend/InvenTree/machine/machine_type.py | 62 ++++++++- .../machine/machine_types/label_printer.py | 7 +- src/backend/InvenTree/machine/registry.py | 3 +- src/backend/InvenTree/machine/serializers.py | 25 +++- src/backend/InvenTree/machine/tasks.py | 23 +++ src/backend/InvenTree/machine/tests.py | 21 +++ .../InvenTree/plugin/machine/__init__.py | 9 +- .../plugin/samples/machines/sample_printer.py | 4 + .../AdminCenter/MachineManagementPanel.tsx | 9 ++ .../src/tables/machine/MachineListTable.tsx | 131 ++++++++++++++++-- 18 files changed, 343 insertions(+), 35 deletions(-) create mode 100644 src/backend/InvenTree/machine/tasks.py 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/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/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/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/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] From d7b4997da225cb98d956f43b4a1a6dc20a420a96 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Mon, 6 Oct 2025 00:04:06 +0200 Subject: [PATCH 2/2] refactor(backend): move serializer context enrichment to mixin (#10456) * add output options for PurchaseOrder, SalesOrder, and ReturnOrder endpoints * add output options for PurchaseOrder, SalesOrder, and ReturnOrder endpoints * add serializer context handling and update sales order fixture with additional line item * bump API version to 398 and update output options tests for PurchaseOrder endpoint * refactor(backend): move serializer context enrichtment to mixin * cleanup other get_serializer instances * add output options tests for SalesOrder and ReturnOrder detail endpoints * fix typo * fix api --------- Co-authored-by: Silver --- src/backend/InvenTree/InvenTree/mixins.py | 9 ++++ src/backend/InvenTree/build/api.py | 15 +----- src/backend/InvenTree/company/api.py | 23 ++++---- src/backend/InvenTree/order/api.py | 66 +++-------------------- src/backend/InvenTree/part/api.py | 33 ++---------- src/backend/InvenTree/stock/api.py | 35 +++--------- src/backend/InvenTree/users/api.py | 9 +--- 7 files changed, 45 insertions(+), 145 deletions(-) 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/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/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/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.