From bd9c52eeaf2af2e16957cb0906b4f1430d12e89d Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 20 Aug 2025 23:00:39 +1000 Subject: [PATCH] Machine registry improvements (#10150) * Add wrapper function for machine registry * Decorate entrypoint functions * Docstrings * Fix for boolean setting * Add playwright tests * Use proper entrypoints * Ensure settings are fetched correctly * Prevent recursion of machine registry decorator * Fix machine status display * Enhanced warning msg * Add simple machine sample printer * Adds playwright tests for machine UI * re-throw exception * Define 'machine' plugin mixin class * Adjust machine discovery * Use plugin mixins for registering machine types and drivers * Adjust unit test * Remove plugin static files when deactivating * Force machine reload when plugin registry changes * Add plugins specific to testing framework * Add test for plugin loading sequence * Add session caching - Significantly reduce DB hits * Enhanced unit testing and test plugins * Refactor unit tests * Further unit test fixes * Adjust instance rendering * Display table of available drivers * Cleanup * ADjust unit test * Tweak unit test * Add docs on new mixin type * Tweak machine overview docs * Tweak playwright tests * Additional unit test * Add unit test for calling machine func * Enhanced playwright tests * Account for database not being ready --- docs/docs/plugins/develop.md | 2 + docs/docs/plugins/machines/label_printer.md | 12 +- docs/docs/plugins/machines/overview.md | 40 +- docs/docs/plugins/mixins/barcode.md | 2 +- docs/docs/plugins/mixins/machine.md | 53 +++ docs/docs/plugins/mixins/mail.md | 2 +- docs/docs/plugins/mixins/schedule.md | 2 +- docs/docs/plugins/mixins/ui.md | 2 +- docs/docs/settings/error_codes.md | 5 + docs/mkdocs.yml | 2 + src/backend/InvenTree/generic/states/tests.py | 4 +- src/backend/InvenTree/machine/api.py | 13 +- src/backend/InvenTree/machine/apps.py | 3 +- src/backend/InvenTree/machine/registry.py | 300 +++++++++++---- src/backend/InvenTree/machine/serializers.py | 3 +- src/backend/InvenTree/machine/test_api.py | 96 +++-- src/backend/InvenTree/machine/tests.py | 343 ++++++++---------- .../plugin/base/integration/MachineMixin.py | 41 +++ .../builtin/integration/machine_types.py | 28 ++ .../InvenTree/plugin/mixins/__init__.py | 2 + src/backend/InvenTree/plugin/models.py | 4 + src/backend/InvenTree/plugin/plugin.py | 1 + src/backend/InvenTree/plugin/registry.py | 4 + .../plugin/samples/machines/__init__.py | 0 .../plugin/samples/machines/sample_printer.py | 63 ++++ src/backend/InvenTree/plugin/test_plugin.py | 5 +- .../InvenTree/plugin/testing/__init__.py | 0 .../plugin/testing/label_machines.py | 99 +++++ .../src/components/render/Generic.tsx | 2 +- src/frontend/src/components/render/Part.tsx | 2 +- src/frontend/src/components/render/Plugin.tsx | 4 +- src/frontend/src/components/render/Report.tsx | 10 +- .../src/components/render/StatusRenderer.tsx | 10 +- src/frontend/src/components/render/Stock.tsx | 4 +- src/frontend/src/components/render/User.tsx | 2 +- .../AdminCenter/MachineManagementPanel.tsx | 15 +- src/frontend/src/states/SettingsStates.tsx | 8 +- .../src/tables/machine/MachineListTable.tsx | 133 +++++-- .../src/tables/machine/MachineTypeTable.tsx | 86 +++-- src/frontend/tests/pui_machines.spec.ts | 113 ++++++ 40 files changed, 1095 insertions(+), 425 deletions(-) create mode 100644 docs/docs/plugins/mixins/machine.md create mode 100644 src/backend/InvenTree/plugin/base/integration/MachineMixin.py create mode 100644 src/backend/InvenTree/plugin/builtin/integration/machine_types.py create mode 100644 src/backend/InvenTree/plugin/samples/machines/__init__.py create mode 100644 src/backend/InvenTree/plugin/samples/machines/sample_printer.py create mode 100644 src/backend/InvenTree/plugin/testing/__init__.py create mode 100644 src/backend/InvenTree/plugin/testing/label_machines.py create mode 100644 src/frontend/tests/pui_machines.spec.ts diff --git a/docs/docs/plugins/develop.md b/docs/docs/plugins/develop.md index 75738f6569..0522f4ea34 100644 --- a/docs/docs/plugins/develop.md +++ b/docs/docs/plugins/develop.md @@ -135,6 +135,8 @@ Supported mixin classes are: | [EventMixin](./mixins/event.md) | Respond to events | | [LabelPrintingMixin](./mixins/label.md) | Custom label printing support | | [LocateMixin](./mixins/locate.md) | Locate and identify stock items | +| [MachineDriverMixin](./mixins/machine.md) | Integrate custom machine drivers +| [MailMixin](./mixins/mail.md) | Send custom emails | | [NavigationMixin](./mixins/navigation.md) | Add custom pages to the web interface | | [NotificationMixin](./mixins/notification.md) | Send custom notifications in response to system events | | [ReportMixin](./mixins/report.md) | Add custom context data to reports | diff --git a/docs/docs/plugins/machines/label_printer.md b/docs/docs/plugins/machines/label_printer.md index 2ca8c3a748..fa35b95ef6 100644 --- a/docs/docs/plugins/machines/label_printer.md +++ b/docs/docs/plugins/machines/label_printer.md @@ -1,12 +1,18 @@ -## Label printer +--- +title: Label Printer Machines +--- + +## Label Printer Label printer machines can directly print labels for various items in InvenTree. They replace standard [`LabelPrintingMixin`](../mixins/label.md) plugins that are used to connect to physical printers. Using machines rather than a standard `LabelPrintingMixin` plugin has the advantage that machines can be created multiple times using different settings but the same driver. That way multiple label printers of the same brand can be connected. -### Writing your own printing driver +### Writing A Custom Driver + +To implement a custom label printer driver, you need to write a plugin which implements the [MachineDriverMixin](../mixins/machine.md) and returns a list of label printer drivers in the `get_machine_drivers` method. Take a look at the most basic required code for a driver in this [example](./overview.md#example-driver). Next either implement the [`print_label`](#machine.machine_types.LabelPrinterBaseDriver.print_label) or [`print_labels`](#machine.machine_types.LabelPrinterBaseDriver.print_labels) function. -### Label printer status +### Label Printer Status There are a couple of predefined status codes for label printers. By default the `UNKNOWN` status code is set for each machine, but they can be changed at any time by the driver. For more info about status code see [Machine status codes](./overview.md#machine-status). diff --git a/docs/docs/plugins/machines/overview.md b/docs/docs/plugins/machines/overview.md index e3d80214c0..20c7e82856 100644 --- a/docs/docs/plugins/machines/overview.md +++ b/docs/docs/plugins/machines/overview.md @@ -13,7 +13,7 @@ InvenTree has a builtin machine registry. There are different machine types avai The machine registry is the main component which gets initialized on server start and manages all configured machines. -#### Initialization process +#### Initialization Process The machine registry initialization process can be divided into three stages: @@ -24,24 +24,27 @@ The machine registry initialization process can be divided into three stages: 2. The driver.init_driver function is called for each used driver 3. The machine.initialize function is called for each machine, which calls the driver.init_machine function for each machine, then the machine.initialized state is set to true -#### Production setup (with a worker) +#### Production Setup + +!!! warning "Cache Required" + A [shared cache](../../start/processes.md#cache-server) is required to run the machine registry in production setup with workers. If a worker is connected, there exist multiple instances of the machine registry (one in each worker thread and one in the main thread) due to the nature of how python handles state in different processes. Therefore the machine instances and drivers are instantiated multiple times (The `__init__` method is called multiple times). But the init functions and update hooks (e.g. `init_machine`) are only called once from the main process. The registry, driver and machine state (e.g. machine status codes, errors, ...) is stored in the cache. Therefore a shared redis cache is needed. (The local in-memory cache which is used by default is not capable to cache across multiple processes) -### Machine types +### Machine Types Each machine type can provide a different type of connection functionality between inventree and a physical machine. These machine types are already built into InvenTree. -#### Built-in types +#### Builtin Types | Name | Description | | --- | --- | | [Label printer](./label_printer.md) | Directly print labels for various items. | -#### Example machine type +#### Example Machine Type If you want to create your own machine type, please also take a look at the already existing machine types in `machines/machine_types/*.py`. The following example creates a machine type called `abc`. @@ -109,24 +112,33 @@ The machine type class gets instantiated for each machine on server startup and Drivers provide the connection layer between physical machines and inventree. There can be multiple drivers defined for the same machine type. Drivers are provided by plugins that are enabled and extend the corresponding base driver for the particular machine type. Each machine type already provides a base driver that needs to be inherited. -#### Example driver +#### Example Driver A basic driver only needs to specify the basic attributes like `SLUG`, `NAME`, `DESCRIPTION`. The others are given by the used base driver, so take a look at [Machine types](#machine-types). The following example will create an driver called `abc` for the `xyz` machine type. The class will be discovered if it is provided by an **installed & activated** plugin just like this: -```py +```python +from plugin.mixins import MachineDriverMixin from plugin import InvenTreePlugin from plugin.machine.machine_types import ABCBaseDriver -class MyXyzAbcDriverPlugin(InvenTreePlugin): - NAME = "XyzAbcDriver" - SLUG = "xyz-driver" - TITLE = "Xyz Abc Driver" - # ... class XYZDriver(ABCBaseDriver): SLUG = 'my-xyz-driver' NAME = 'My XYZ driver' DESCRIPTION = 'This is an awesome XYZ driver for a ABC machine' + + +class MyXyzAbcDriverPlugin(MachineDriverMixin, InvenTreePlugin): + NAME = "XyzAbcDriver" + SLUG = "xyz-driver" + TITLE = "Xyz Abc Driver" + # ... + + def get_machine_drivers(self): + """Return a list of machine drivers for this plugin.""" + return [XYZDriver] + + ``` #### Driver API @@ -161,7 +173,7 @@ class MyXYZDriver(ABCBaseDriver): Settings can even marked as `'required': True` which prevents the machine from starting if the setting is not defined. -### Machine status +### Machine Status Machine status can be used to report the machine status to the users. They can be set by the driver for each machine, but get lost on a server restart. @@ -186,7 +198,7 @@ class XYZMachineType(BaseMachineType): And to set a status code for a machine by the driver. -```py +```python class MyXYZDriver(ABCBaseDriver): # ... def init_machine(self, machine): diff --git a/docs/docs/plugins/mixins/barcode.md b/docs/docs/plugins/mixins/barcode.md index 76a6356e36..962e36da31 100644 --- a/docs/docs/plugins/mixins/barcode.md +++ b/docs/docs/plugins/mixins/barcode.md @@ -2,7 +2,7 @@ title: Barcode Mixin --- -## Barcode Plugins +## BarcodeMixin InvenTree supports decoding of arbitrary barcode data and generation of internal barcode formats via a **Barcode Plugin** interface. Barcode data POSTed to the `/api/barcode/` endpoint will be supplied to all loaded barcode plugins, and the first plugin to successfully interpret the barcode data will return a response to the client. diff --git a/docs/docs/plugins/mixins/machine.md b/docs/docs/plugins/mixins/machine.md new file mode 100644 index 0000000000..c8683da248 --- /dev/null +++ b/docs/docs/plugins/mixins/machine.md @@ -0,0 +1,53 @@ +--- +title: Machine Mixin +--- + +## MachineDriverMixin + +The `MachineDriverMixin` class is used to implement custom machine drivers (or machine types) in InvenTree. + +InvenTree supports integration with [external machines](../machines/overview.md), through the use of plugin-supplied device drivers. + +### get_machine_drivers + +To register custom machine driver(s), the `get_machine_drivers` method must be implemented. This method should return a list of machine driver classes that the plugin supports. + +::: plugin.base.integration.MachineMixin.MachineDriverMixin.get_machine_drivers + options: + show_bases: False + show_root_heading: False + show_root_toc_entry: False + summary: False + members: [] + extra: + show_source: True + +The default implementation returns an empty list, meaning no custom machine drivers are registered. + +### get_machine_types + +To register custom machine type(s), the `get_machine_types` method must be implemented. This method should return a list of machine type classes that the plugin supports. + +::: plugin.base.integration.MachineMixin.MachineDriverMixin.get_machine_types + options: + show_bases: False + show_root_heading: False + show_root_toc_entry: False + summary: False + members: [] + extra: + show_source: True + +The default implementation returns an empty list, meaning no custom machine types are registered. + +## Sample Plugin + +A sample plugin is provided which implements a simple [label printing](../machines/label_printer.md) machine driver: + +::: plugin.samples.machines.sample_printer.SamplePrinterMachine + options: + show_bases: False + show_root_heading: False + show_root_toc_entry: False + show_source: True + members: [] diff --git a/docs/docs/plugins/mixins/mail.md b/docs/docs/plugins/mixins/mail.md index d5899bc068..8fb0ff0f6e 100644 --- a/docs/docs/plugins/mixins/mail.md +++ b/docs/docs/plugins/mixins/mail.md @@ -4,7 +4,7 @@ title: Mail Mixin ## MailMixin -The MailMixin class provides basic functionality for processing in- and outgoing mails. +The `MailMixin` class provides basic functionality for processing in- and outgoing mails. ### Sample Plugin diff --git a/docs/docs/plugins/mixins/schedule.md b/docs/docs/plugins/mixins/schedule.md index 25af4999c5..8e2243dee7 100644 --- a/docs/docs/plugins/mixins/schedule.md +++ b/docs/docs/plugins/mixins/schedule.md @@ -16,7 +16,7 @@ The ScheduleMixin class provides a plugin with the ability to call functions at {{ image("plugin/enable_schedule.png", "Enable schedule integration") }} -### SamplePlugin +### Sample Plugin An example of a plugin which supports scheduled tasks: diff --git a/docs/docs/plugins/mixins/ui.md b/docs/docs/plugins/mixins/ui.md index f55c60fca9..4510ae0793 100644 --- a/docs/docs/plugins/mixins/ui.md +++ b/docs/docs/plugins/mixins/ui.md @@ -2,7 +2,7 @@ title: User Interface Mixin --- -## User Interface Mixin +## UserInterfaceMixin The `UserInterfaceMixin` class provides a set of methods to implement custom functionality for the InvenTree web interface. diff --git a/docs/docs/settings/error_codes.md b/docs/docs/settings/error_codes.md index 2c7f8dda48..0b422441d0 100644 --- a/docs/docs/settings/error_codes.md +++ b/docs/docs/settings/error_codes.md @@ -75,6 +75,11 @@ A plugin is not allowed to override a *final method* from the `InvenTreePlugin` This is a security measure to prevent plugins from changing the core functionality of InvenTree. The code of the plugin must be changed to not override functions that are marked as *final*. +#### INVE-E12 +**Plugin returned invalid machine type - Backend** + +An error occurred when discovering or initializing a machine type from a plugin. This likely indicates a faulty or incompatible plugin. + ### INVE-W (InvenTree Warning) Warnings - These are non-critical errors which should be addressed when possible. diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 9df2e163a5..c4e1f8ed20 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -228,6 +228,8 @@ nav: - Icon Pack Mixin: plugins/mixins/icon.md - Label Printing Mixin: plugins/mixins/label.md - Locate Mixin: plugins/mixins/locate.md + - Machine Mixin: plugins/mixins/machine.md + - Mail Mixin: plugins/mixins/mail.md - Navigation Mixin: plugins/mixins/navigation.md - Notification Mixin: plugins/mixins/notification.md - Report Mixin: plugins/mixins/report.md diff --git a/src/backend/InvenTree/generic/states/tests.py b/src/backend/InvenTree/generic/states/tests.py index abb29369bd..943e88068d 100644 --- a/src/backend/InvenTree/generic/states/tests.py +++ b/src/backend/InvenTree/generic/states/tests.py @@ -230,7 +230,7 @@ class ApiTests(InvenTreeAPITestCase): response = self.get(reverse('api-status-all')) # 10 built-in state classes, plus the added GeneralState class - self.assertEqual(len(response.data), 12) + self.assertEqual(len(response.data), 11) # Test the BuildStatus model build_status = response.data['BuildStatus'] @@ -270,7 +270,7 @@ class ApiTests(InvenTreeAPITestCase): ) response = self.get(reverse('api-status-all')) - self.assertEqual(len(response.data), 12) + self.assertEqual(len(response.data), 11) stock_status_cstm = response.data['StockStatus'] self.assertEqual(stock_status_cstm['status_class'], 'StockStatus') diff --git a/src/backend/InvenTree/machine/api.py b/src/backend/InvenTree/machine/api.py index 1807b30b23..1f370d5974 100644 --- a/src/backend/InvenTree/machine/api.py +++ b/src/backend/InvenTree/machine/api.py @@ -167,7 +167,7 @@ class MachineTypesList(APIView): @extend_schema(responses={200: MachineSerializers.MachineTypeSerializer(many=True)}) def get(self, request): """List all machine types.""" - machine_types = list(registry.machine_types.values()) + machine_types = list(registry.get_machine_types()) results = MachineSerializers.MachineTypeSerializer( machine_types, many=True ).data @@ -175,10 +175,7 @@ class MachineTypesList(APIView): class MachineDriverList(APIView): - """List API Endpoint for all discovered machine drivers. - - - GET: List all machine drivers - """ + """List API Endpoint for all discovered machine driver types.""" permission_classes = [InvenTree.permissions.IsAuthenticatedOrReadScope] @@ -187,9 +184,9 @@ class MachineDriverList(APIView): ) def get(self, request): """List all machine drivers.""" - drivers = registry.drivers.values() - if machine_type := request.query_params.get('machine_type', None): - drivers = filter(lambda d: d.machine_type == machine_type, drivers) + machine_type = request.query_params.get('machine_type', None) + + drivers = registry.get_driver_types(machine_type) results = MachineSerializers.MachineDriverSerializer( list(drivers), many=True diff --git a/src/backend/InvenTree/machine/apps.py b/src/backend/InvenTree/machine/apps.py index 0f47f36769..95ab925dc1 100755 --- a/src/backend/InvenTree/machine/apps.py +++ b/src/backend/InvenTree/machine/apps.py @@ -36,7 +36,8 @@ class MachineConfig(AppConfig): try: logger.info('Loading InvenTree machines') - registry.initialize(main=isInMainThread()) + if not registry.is_ready: + registry.initialize(main=isInMainThread()) except (OperationalError, ProgrammingError): # Database might not yet be ready logger.warn('Database was not ready for initializing machines') diff --git a/src/backend/InvenTree/machine/registry.py b/src/backend/InvenTree/machine/registry.py index 23affee311..7121b3c388 100644 --- a/src/backend/InvenTree/machine/registry.py +++ b/src/backend/InvenTree/machine/registry.py @@ -1,20 +1,96 @@ """Machine registry.""" -from typing import Union, cast +import functools +from typing import Any, Optional, Union, cast from uuid import UUID -from django.core.cache import cache from django.db.utils import IntegrityError, OperationalError, ProgrammingError import structlog +import InvenTree.cache from common.settings import get_global_setting, set_global_setting +from InvenTree.exceptions import log_error from InvenTree.helpers_mixin import get_shared_class_instance_state_mixin from machine.machine_type import BaseDriver, BaseMachineType logger = structlog.get_logger('inventree') +def machine_registry_entrypoint( + check_reload: bool = True, check_ready: bool = True, default_value: Any = None +) -> Any: + """Decorator for any method which should be registered as a machine registry entrypoint. + + This decorator ensures that the plugin registry is up-to-date, + and reloads the machine registry if necessary. + """ + + def decorator(method): + """Internal decorator for the machine registry entrypoint.""" + + @functools.wraps(method) + def wrapper(self, *args, **kwargs): + """Wrapper function to ensure the machine registry is up-to-date.""" + # Ensure the plugin registry is up-to-date + from plugin import registry as plg_registry + + logger.debug("machine_registry_entrypoint: '%s'", method.__name__) + + if check_ready and not self.ready: + logger.warning( + "Machine registry is not ready - cannot call method '%s'", + method.__name__, + ) + + return default_value + + do_reload = False + + if InvenTree.cache.get_session_cache('machine_registry_checked'): + # Short circuit if we have already checked within this session + pass + + elif not getattr(self, '__checking_reload', False): + # Avoid recursive reloads + do_reload = True + self.__checking_reload = True + + if check_reload: + if plg_registry.check_reload(): + # The plugin registry changed - update the machine registry too + logger.info( + 'Plugin registry changed - reloading machine registry' + ) + self.reload_machines() + + else: + # Check if the machine registry needs to be reloaded + self._check_reload() + + self.__checking_reload = False + + InvenTree.cache.set_session_cache('machine_registry_checked', True) + + # Call the original method + try: + result = method(self, *args, **kwargs) + except Exception as e: + log_error(method.__name__, scope='machine_registry') + result = default_value + raise e + finally: + # If we reloaded the registry, we need to update the registry hash + if do_reload: + self._update_registry_hash() + + return result + + return wrapper + + return decorator + + class MachineRegistry( get_shared_class_instance_state_mixin(lambda _x: 'machine:registry') ): @@ -32,6 +108,8 @@ class MachineRegistry( self.base_drivers: list[type[BaseDriver]] = [] + self.ready: bool = False + # Keep an internal hash of the machine registry state self._hash = None @@ -40,47 +118,65 @@ class MachineRegistry( """List of registry errors.""" return cast(list[Union[str, Exception]], self.get_shared_state('errors', [])) + @property + def is_ready(self) -> bool: + """Check if the machine registry is ready.""" + return self.ready + 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]) + @machine_registry_entrypoint(check_reload=False, check_ready=False) def initialize(self, main: bool = False): """Initialize the machine registry.""" - # clear cache for machines (only needed for global redis cache) - if main and hasattr(cache, 'delete_pattern'): # pragma: no cover - cache.delete_pattern('machine:*') - - self.discover_machine_types() - self.discover_drivers() - self.load_machines(main=main) + self.ready = True + self.reload_machines(main=main) def discover_machine_types(self): - """Discovers all machine types by inferring all classes that inherit the BaseMachineType class.""" - import InvenTree.helpers + """Discovers all machine types by discovering all plugins which implement the Machine mixin class.""" + from plugin import PluginMixinEnum + from plugin.registry import registry as plugin_registry logger.debug('Collecting machine types') machine_types: dict[str, type[BaseMachineType]] = {} base_drivers: list[type[BaseDriver]] = [] - discovered_machine_types: set[type[BaseMachineType]] = ( - InvenTree.helpers.inheritors(BaseMachineType) - ) - for machine_type in discovered_machine_types: + for plugin in plugin_registry.with_mixin(PluginMixinEnum.MACHINE): try: - machine_type.validate() - except NotImplementedError as error: - self.handle_error(error) - continue + for machine_type in plugin.get_machine_types(): + if not issubclass(machine_type, BaseMachineType): + logger.error( + 'INVE-E12: Plugin %s returned invalid machine type', + plugin.slug, + ) + continue - if machine_type.SLUG in machine_types: - self.handle_error( - ValueError(f"Cannot re-register machine type '{machine_type.SLUG}'") + try: + machine_type.validate() + except NotImplementedError as error: + self.handle_error(error) + continue + + if machine_type.SLUG in machine_types: + self.handle_error( + ValueError( + f"Cannot re-register machine type '{machine_type.SLUG}'" + ) + ) + continue + + machine_types[machine_type.SLUG] = machine_type + base_drivers.append(machine_type.base_driver) + + except Exception as error: + log_error( + 'discover_machine_types', + plugin=plugin.slug, + scope='MachineRegistry', ) - continue - - machine_types[machine_type.SLUG] = machine_type - base_drivers.append(machine_type.base_driver) + self.handle_error(error) self.machine_types = machine_types self.base_drivers = base_drivers @@ -88,39 +184,47 @@ class MachineRegistry( logger.debug('Found %s machine types', len(self.machine_types.keys())) def discover_drivers(self): - """Discovers all machine drivers by inferring all classes that inherit the BaseDriver class.""" - import InvenTree.helpers + """Discovers all machine drivers by discovering all plugins which implement the Machine mixin class.""" + from plugin import PluginMixinEnum + from plugin.registry import registry as plugin_registry logger.debug('Collecting machine drivers') - drivers: dict[str, type[BaseDriver]] = {} - discovered_drivers: set[type[BaseDriver]] = InvenTree.helpers.inheritors( - BaseDriver - ) - for driver in discovered_drivers: - # skip discovered drivers that define a base driver for a machine type - if driver in self.base_drivers: - continue - + for plugin in plugin_registry.with_mixin(PluginMixinEnum.MACHINE): try: - driver.validate() - except NotImplementedError as error: - self.handle_error(error) - continue + for driver in plugin.get_machine_drivers(): + if not issubclass(driver, BaseDriver): + logger.error( + 'INVE-E12: Plugin %s returned invalid driver type', + plugin.slug, + ) + continue - if driver.SLUG in drivers: - self.handle_error( - ValueError(f"Cannot re-register driver '{driver.SLUG}'") + try: + driver.validate() + except NotImplementedError as error: + self.handle_error(error) + continue + + if driver.SLUG in drivers: + self.handle_error( + ValueError(f"Cannot re-register driver '{driver.SLUG}'") + ) + continue + + drivers[driver.SLUG] = driver + except Exception as error: + log_error( + 'discover_drivers', plugin=plugin.slug, scope='MachineRegistry' ) - continue - - drivers[driver.SLUG] = driver + self.handle_error(error) self.drivers = drivers logger.debug('Found %s machine drivers', len(self.drivers.keys())) + @machine_registry_entrypoint() def get_driver_instance(self, slug: str): """Return or create a driver instance if needed.""" if slug not in self.driver_instances: @@ -132,15 +236,23 @@ class MachineRegistry( return self.driver_instances.get(slug, None) + @machine_registry_entrypoint() def load_machines(self, main: bool = False): """Load all machines defined in the database into the machine registry.""" # Imports need to be in this level to prevent early db model imports - from machine.models import MachineConfig - for machine_config in MachineConfig.objects.all(): - self.add_machine( - machine_config, initialize=False, update_registry_hash=False - ) + try: + from machine.models import MachineConfig + + for machine_config in MachineConfig.objects.all(): + self.add_machine( + machine_config, initialize=False, update_registry_hash=False + ) + except (OperationalError, ProgrammingError): + logger.warning('Database is not ready - cannot load machines') + + self._update_registry_hash() + return # initialize machines only in main thread if main: @@ -159,11 +271,21 @@ class MachineRegistry( self._update_registry_hash() - def reload_machines(self): + def reload_machines(self, main: bool = False): """Reload all machines from the database.""" + self.drivers = {} + self.driver_instances = {} self.machines = {} - self.load_machines() + InvenTree.cache.set_session_cache('machine_registry_checked', False) + + self.set_shared_state('errors', []) + + self.discover_machine_types() + self.discover_drivers() + self.load_machines(main=main) + + @machine_registry_entrypoint() def add_machine(self, machine_config, initialize=True, update_registry_hash=True): """Add a machine to the machine registry.""" machine_type = self.machine_types.get(machine_config.machine_type, None) @@ -180,6 +302,7 @@ class MachineRegistry( if update_registry_hash: self._update_registry_hash() + @machine_registry_entrypoint() def update_machine( self, old_machine_state, machine_config, update_registry_hash=True ): @@ -190,15 +313,18 @@ class MachineRegistry( if update_registry_hash: self._update_registry_hash() + @machine_registry_entrypoint() def restart_machine(self, machine): """Restart a machine.""" machine.restart() + @machine_registry_entrypoint() def remove_machine(self, machine: BaseMachineType): """Remove a machine from the registry.""" self.machines.pop(str(machine.pk), None) self._update_registry_hash() + @machine_registry_entrypoint(default_value=False) def get_machines(self, **kwargs): """Get loaded machines from registry (By default only initialized machines). @@ -210,8 +336,6 @@ class MachineRegistry( active: (bool) base_driver: base driver (class) """ - self._check_reload() - allowed_fields = [ 'name', 'machine_type', @@ -253,17 +377,40 @@ class MachineRegistry( return list(filter(filter_machine, self.machines.values())) + @machine_registry_entrypoint(default_value=[]) + def get_machine_types(self): + """Get all machine types.""" + return list(self.machine_types.values()) + + @machine_registry_entrypoint() def get_machine(self, pk: Union[str, UUID]): """Get machine from registry by pk.""" - self._check_reload() return self.machines.get(str(pk), None) - def get_drivers(self, machine_type: str): - """Get all drivers for a specific machine type.""" + @machine_registry_entrypoint(default_value=[]) + def get_driver_types(self, machine_type: Optional[str] = None): + """Return a list of all registered driver types. + + Arguments: + machine_type: Optional machine type to filter drivers by their machine type + """ + return [ + driver + for driver in self.drivers.values() + if machine_type is None or driver.machine_type == machine_type + ] + + @machine_registry_entrypoint(default_value=[]) + def get_drivers(self, machine_type: Optional[str] = None): + """Get all drivers for a specific machine type. + + Arguments: + machine_type: Optional machine type to filter drivers by their machine type + """ return [ driver for driver in self.driver_instances.values() - if driver.machine_type == machine_type + if machine_type is None or driver.machine_type == machine_type ] def _calculate_registry_hash(self): @@ -290,6 +437,14 @@ class MachineRegistry( def _check_reload(self): """Check if the registry needs to be reloaded, and reload it.""" + from plugin import registry as plg_registry + + do_reload: bool = False + plugin_registry_hash = getattr(self, '_plugin_registry_hash', None) + + if plugin_registry_hash != plg_registry.registry_hash: + do_reload = True + if not self._hash: self._hash = self._calculate_registry_hash() @@ -301,14 +456,19 @@ class MachineRegistry( if reg_hash and reg_hash != self._hash: logger.info('Machine registry has changed - reloading machines') - self.reload_machines() - return True + do_reload = True - return False + if do_reload: + self.reload_machines() + + return do_reload def _update_registry_hash(self): """Save the current registry hash.""" + from plugin import registry as plg_registry + self._hash = self._calculate_registry_hash() + self._plugin_registry_hash = plg_registry.registry_hash try: old_hash = get_global_setting('_MACHINE_REGISTRY_HASH') @@ -324,9 +484,10 @@ class MachineRegistry( except Exception as exc: logger.exception('Failed to update machine registry hash: %s', str(exc)) + @machine_registry_entrypoint() def call_machine_function( self, machine_id: str, function_name: str, *args, **kwargs - ): + ) -> Any: """Call a named function against a machine instance. Arguments: @@ -337,8 +498,6 @@ class MachineRegistry( raise_error = kwargs.pop('raise_error', True) - self._check_reload() - # Fetch the machine instance based on the provided UUID machine = self.get_machine(machine_id) @@ -372,5 +531,12 @@ registry: MachineRegistry = MachineRegistry() def call_machine_function(machine_id: str, function: str, *args, **kwargs): - """Global helper function to call a specific function on a machine instance.""" + """Global helper function to call a specific function on a machine instance. + + Arguments: + machine_id: The UUID of the machine to call the function against + function: The name of the function to call + *args: Positional arguments to pass to the function + **kwargs: Keyword arguments to pass to the function + """ return registry.call_machine_function(machine_id, function, *args, **kwargs) diff --git a/src/backend/InvenTree/machine/serializers.py b/src/backend/InvenTree/machine/serializers.py index e1f75632e3..fcf75bd5dd 100644 --- a/src/backend/InvenTree/machine/serializers.py +++ b/src/backend/InvenTree/machine/serializers.py @@ -167,7 +167,8 @@ class MachineDriverSerializer(BaseMachineClassSerializer): def get_errors(self, obj) -> list[str]: """Serializer method for the errors field.""" - driver_instance = registry.driver_instances.get(obj.SLUG, None) + driver_instance = registry.get_driver_instance(obj.SLUG) + if driver_instance is None: return [] return [str(err) for err in driver_instance.errors] diff --git a/src/backend/InvenTree/machine/test_api.py b/src/backend/InvenTree/machine/test_api.py index 38c1e4f021..20e9330542 100644 --- a/src/backend/InvenTree/machine/test_api.py +++ b/src/backend/InvenTree/machine/test_api.py @@ -7,10 +7,10 @@ from django.urls import reverse from InvenTree.unit_test import InvenTreeAPITestCase from machine import registry -from machine.machine_type import BaseDriver, BaseMachineType -from machine.machine_types import LabelPrinterBaseDriver +from machine.machine_type import BaseDriver from machine.models import MachineConfig from machine.tests import TestMachineRegistryMixin +from plugin.registry import registry as plg_registry from stock.models import StockLocation @@ -21,57 +21,11 @@ class MachineAPITest(TestMachineRegistryMixin, InvenTreeAPITestCase): def setUp(self): """Setup some testing drivers/machines.""" - - class TestingLabelPrinterDriver(LabelPrinterBaseDriver): - """Test driver for label printing.""" - - SLUG = 'test-label-printer-api' - NAME = 'Test label printer' - DESCRIPTION = 'This is a test label printer driver for testing.' - - MACHINE_SETTINGS = { - 'TEST_SETTING': { - 'name': 'Test setting', - 'description': 'This is a test setting', - } - } - - def restart_machine(self, machine: BaseMachineType): - """Override restart_machine.""" - machine.set_status_text('Restarting...') - - def print_label(self, *args, **kwargs) -> None: - """Override print_label.""" - - class TestingLabelPrinterDriverError1(LabelPrinterBaseDriver): - """Test driver for label printing.""" - - SLUG = 'test-label-printer-error' - NAME = 'Test label printer error' - DESCRIPTION = 'This is a test label printer driver for testing.' - - def print_label(self, *args, **kwargs) -> None: - """Override print_label.""" - - class TestingLabelPrinterDriverError2(LabelPrinterBaseDriver): - """Test driver for label printing.""" - - SLUG = 'test-label-printer-error' - NAME = 'Test label printer error' - DESCRIPTION = 'This is a test label printer driver for testing.' - - def print_label(self, *args, **kwargs) -> None: - """Override print_label.""" - - class TestingLabelPrinterDriverNotImplemented(LabelPrinterBaseDriver): - """Test driver for label printing.""" - - SLUG = 'test-label-printer-not-implemented' - NAME = 'Test label printer error not implemented' - DESCRIPTION = 'This is a test label printer driver for testing.' - registry.initialize() + # Ensure the test plugin is loaded + plg_registry.set_plugin_state('label-printer-test-plugin', True) + super().setUp() def test_machine_type_list(self): @@ -99,6 +53,8 @@ class MachineAPITest(TestMachineRegistryMixin, InvenTreeAPITestCase): def test_machine_driver_list(self): """Test machine driver list API endpoint.""" + # Enable the built-in + response = self.get(reverse('api-machine-drivers')) driver = [a for a in response.data if a['slug'] == 'test-label-printer-api'] self.assertEqual(len(driver), 1) @@ -116,7 +72,11 @@ class MachineAPITest(TestMachineRegistryMixin, InvenTreeAPITestCase): 'driver_errors': [], }, ) - self.assertEqual(driver['provider_file'], __file__) + + # Check that the driver is provided from the correct plugin file + self.assertTrue( + driver['provider_file'].endswith('plugin/testing/label_machines.py') + ) # Test driver with errors driver_instance = cast( @@ -133,7 +93,11 @@ class MachineAPITest(TestMachineRegistryMixin, InvenTreeAPITestCase): def test_machine_status(self): """Test machine status API endpoint.""" - response = self.get(reverse('api-machine-registry-status')) + # Force a registry reload to ensure all machines are registered + registry.reload_machines() + + url = reverse('api-machine-registry-status') + response = self.get(url) errors_msgs = [e['message'] for e in response.data['registry_errors']] required_patterns = [ @@ -197,8 +161,9 @@ class MachineAPITest(TestMachineRegistryMixin, InvenTreeAPITestCase): } # Create a machine + # Note: Many DB hits as the entire machine registry is reloaded response = self.post( - reverse('api-machine-list'), machine_data, max_query_count=150 + reverse('api-machine-list'), machine_data, max_query_count=300 ) self.assertEqual(response.data, {**response.data, **machine_data}) @@ -290,6 +255,29 @@ class MachineAPITest(TestMachineRegistryMixin, InvenTreeAPITestCase): [(s['config_type'], s['key']) for s in response.data], ) + def test_machine_settings_list(self): + """Test machine settings list API endpoint.""" + machine = MachineConfig.objects.create( + machine_type='label-printer', + driver='test-label-printer-api', + name='Test Machine', + active=True, + ) + + url = reverse('api-machine-settings', kwargs={'pk': machine.pk}) + response = self.get(url) + + self.assertEqual(len(response.data), 2) + + keys = [s['key'] for s in response.data] + + self.assertIn('LOCATION', keys) + self.assertIn('TEST_SETTING', keys) + + for item in response.data: + for key in ['api_url', 'pk', 'typ', 'key']: + self.assertIn(key, item) + def test_machine_restart(self): """Test machine restart API endpoint.""" machine = MachineConfig.objects.create( diff --git a/src/backend/InvenTree/machine/tests.py b/src/backend/InvenTree/machine/tests.py index e103644962..cedef45648 100755 --- a/src/backend/InvenTree/machine/tests.py +++ b/src/backend/InvenTree/machine/tests.py @@ -1,17 +1,12 @@ """Machine app tests.""" from typing import cast -from unittest.mock import MagicMock, Mock from django.apps import apps from django.test import TestCase from django.urls import reverse -from rest_framework import serializers - from InvenTree.unit_test import AdminTestCase, InvenTreeAPITestCase -from machine.machine_type import BaseDriver, BaseMachineType, MachineStatus -from machine.machine_types.label_printer import LabelPrinterBaseDriver from machine.models import MachineConfig from machine.registry import registry from part.models import Part @@ -42,102 +37,90 @@ class TestDriverMachineInterface(TestMachineRegistryMixin, TestCase): def setUp(self): """Setup some testing drivers/machines.""" - - class TestingMachineBaseDriver(BaseDriver): - """Test base driver for testing machines.""" - - machine_type = 'testing-type' - - class TestingMachineType(BaseMachineType): - """Test machine type for testing.""" - - SLUG = 'testing-type' - NAME = 'Testing machine type' - DESCRIPTION = 'This is a test machine type for testing.' - - base_driver = TestingMachineBaseDriver - - class TestingMachineTypeStatus(MachineStatus): - """Test machine status.""" - - UNKNOWN = 100, 'Unknown', 'secondary' - - MACHINE_STATUS = TestingMachineTypeStatus - default_machine_status = MACHINE_STATUS.UNKNOWN - - class TestingDriver(TestingMachineBaseDriver): - """Test driver for testing machines.""" - - SLUG = 'test-driver' - NAME = 'Test Driver' - DESCRIPTION = 'This is a test driver for testing.' - - MACHINE_SETTINGS = { - 'TEST_SETTING': {'name': 'Test Setting', 'description': 'Test setting'} - } - - # mock driver implementation - self.driver_mocks = { - k: Mock() - for k in [ - 'init_driver', - 'init_machine', - 'update_machine', - 'restart_machine', - ] - } - - for key, value in self.driver_mocks.items(): - setattr(TestingDriver, key, value) - - self.machine1 = MachineConfig.objects.create( - name='Test Machine 1', - machine_type='testing-type', - driver='test-driver', - active=True, - ) - self.machine2 = MachineConfig.objects.create( - name='Test Machine 2', - machine_type='testing-type', - driver='test-driver', - active=True, - ) - self.machine3 = MachineConfig.objects.create( - name='Test Machine 3', - machine_type='testing-type', - driver='test-driver', - active=False, - ) - self.machines = [self.machine1, self.machine2, self.machine3] - - # init registry + plg_registry.reload_plugins() registry.initialize(main=True) - # mock machine implementation - self.machine_mocks = { - m: {k: MagicMock() for k in ['update', 'restart']} for m in self.machines - } - for machine_config, mock_dict in self.machine_mocks.items(): - for key, mock in mock_dict.items(): - mock.side_effect = getattr(machine_config.machine, key) - setattr(machine_config.machine, key, mock) - super().setUp() def test_machine_lifecycle(self): """Test the machine lifecycle.""" + # Check initial conditions of the machine registry + self.assertEqual(len(registry.get_machine_types()), 1) + self.assertEqual(len(registry.get_driver_types()), 0) + self.assertEqual(len(registry.get_machines()), 0) + + # Enable sample plugin + plg_registry.set_plugin_state('sample-printer-machine-plugin', True) + + # Check state now + self.assertEqual(len(registry.get_machine_types()), 1) + self.assertEqual(len(registry.get_driver_types()), 1) + self.assertEqual(len(registry.get_machines()), 0) + + # Enable test plugin + plg_registry.set_plugin_state('label-printer-test-plugin', True) + + # Check state now + self.assertEqual(len(registry.get_machine_types()), 1) + self.assertEqual(len(registry.get_driver_types()), 3) + self.assertEqual(len(registry.get_machines()), 0) + + # Check for expected machine registry errors + self.assertEqual(len(registry.errors), 2) + self.assertIn( + "Cannot re-register driver 'test-label-printer-error'", + str(registry.errors[0]), + ) + self.assertIn( + 'did not override the required attributes', str(registry.errors[1]) + ) + + # Check for expected machine types + for slug in [ + 'sample-printer-driver', + 'test-label-printer-api', + 'test-label-printer-error', + ]: + instance = registry.get_driver_instance(slug) + self.assertIsNotNone(instance, f"Driver '{slug}' should be registered") + + # Next, un-register some plugins + plg_registry.set_plugin_state('label-printer-test-plugin', False) + + self.assertEqual(len(registry.get_driver_types()), 1) + self.assertEqual(len(registry.errors), 0) + + driver = registry.get_driver_types()[0] + self.assertEqual(driver.SLUG, 'sample-printer-driver') + + # Create some new label printing machines + machines = [ + MachineConfig.objects.create( + name=f'Test Machine {i}', + machine_type='label-printer', + driver=driver.SLUG, + active=i < 3, + ) + for i in range(1, 4) + ] + + self.assertEqual(MachineConfig.objects.count(), 3) + # test that the registry is initialized correctly self.assertEqual(len(registry.machines), 3) self.assertEqual(len(registry.driver_instances), 1) # test get_machines - self.assertEqual(len(registry.get_machines()), 2) self.assertEqual(len(registry.get_machines(initialized=None)), 3) + self.assertEqual(len(registry.get_machines(active=True)), 2) self.assertEqual(len(registry.get_machines(active=False, initialized=False)), 1) + self.assertEqual(len(registry.get_machines(name='Test Machine 1')), 1) + self.assertEqual( len(registry.get_machines(name='Test Machine 1', active=False)), 0 ) + self.assertEqual( len(registry.get_machines(name='Test Machine 1', active=True)), 1 ) @@ -150,104 +133,87 @@ class TestDriverMachineInterface(TestMachineRegistryMixin, TestCase): registry.get_machines(unknown_filter='test') # test get_machine - self.assertEqual(registry.get_machine(self.machine1.pk), self.machine1.machine) - - # test get_drivers - self.assertEqual(len(registry.get_drivers('testing-type')), 1) - self.assertEqual(registry.get_drivers('testing-type')[0].SLUG, 'test-driver') - - # test that init hooks where called correctly - CALL_COUNT = range(1, 5) # Due to interplay between plugin and machine registry - self.assertIn(self.driver_mocks['init_driver'].call_count, CALL_COUNT) - self.assertIn(self.driver_mocks['init_machine'].call_count, CALL_COUNT) + machine = machines[0] + self.assertEqual(registry.get_machine(machine.pk), machine.machine) # Test machine restart hook - registry.restart_machine(self.machine1.machine) - - self.assertEqual(self.machine_mocks[self.machine1]['restart'].call_count, 1) - - # Test machine update hook - self.machine1.name = 'Test Machine 1 - Updated' - self.machine1.save() - self.driver_mocks['update_machine'].assert_called_once() - self.assertEqual(self.machine_mocks[self.machine1]['update'].call_count, 1) - old_machine_state, machine = self.driver_mocks['update_machine'].call_args.args - self.assertEqual(old_machine_state['name'], 'Test Machine 1') - self.assertEqual(machine.name, 'Test Machine 1 - Updated') - self.assertEqual(self.machine1.machine, machine) - self.machine_mocks[self.machine1]['update'].reset_mock() - - # get ref to machine 1 - machine1: BaseMachineType = self.machine1.machine # type: ignore - self.assertIsNotNone(machine1) - - # Test machine setting update hook - self.assertEqual(machine1.get_setting('TEST_SETTING', 'D'), '') - machine1.set_setting('TEST_SETTING', 'D', 'test-value') - self.assertEqual(self.machine_mocks[self.machine1]['update'].call_count, 2) - old_machine_state, machine = self.driver_mocks['update_machine'].call_args.args - self.assertEqual(old_machine_state['settings']['D', 'TEST_SETTING'], '') - self.assertEqual(machine1.get_setting('TEST_SETTING', 'D'), 'test-value') - self.assertEqual(self.machine1.machine, machine) + registry.restart_machine(machine.machine) # Test remove machine self.assertEqual(len(registry.get_machines()), 2) - registry.remove_machine(machine1) + registry.remove_machine(machine) self.assertEqual(len(registry.get_machines()), 1) -class TestLabelPrinterMachineType(TestMachineRegistryMixin, InvenTreeAPITestCase): +class TestLabelPrinterMachineType(InvenTreeAPITestCase): """Test the label printer machine type.""" fixtures = ['category', 'part', 'location', 'stock'] - def setUp(self): - """Setup the label printer machine type.""" - super().setUp() + def test_registration(self): + """Test that the machine is correctly registered from the plugin.""" + PLG_KEY = 'label-printer-test-plugin' + DRIVER_KEY = 'test-label-printer-api' - class TestingLabelPrinterDriver(LabelPrinterBaseDriver): - """Label printer driver for testing.""" + # Test that the machine is only available when the plugin is enabled + for enabled in [False, True, False, True, False]: + plg_registry.set_plugin_state(PLG_KEY, enabled) + machine = registry.get_driver_instance(DRIVER_KEY) + if enabled: + self.assertIsNotNone(machine) + else: + self.assertIsNone(machine) - SLUG = 'testing-label-printer' - NAME = 'Testing Label Printer' - DESCRIPTION = 'This is a test label printer driver for testing.' + def create_machine(self): + """Create a new label printing machine.""" + registry.initialize(main=True) - class PrintingOptionsSerializer( - LabelPrinterBaseDriver.PrintingOptionsSerializer - ): - """Test printing options serializer.""" + PLG_KEY = 'label-printer-test-plugin' + DRIVER_KEY = 'test-label-printer-api' - test_option = serializers.IntegerField() + # Ensure that the driver is initialized + plg_registry.set_plugin_state(PLG_KEY, True) - def print_label(self, *args, **kwargs): - """Mock print label method so that there are no errors.""" + driver = registry.get_driver_instance(DRIVER_KEY) + self.assertIsNotNone(driver) - self.machine = MachineConfig.objects.create( + machine_config = MachineConfig.objects.create( name='Test Label Printer', machine_type='label-printer', - driver='testing-label-printer', + driver=DRIVER_KEY, active=True, ) - registry.initialize(main=True) - driver_instance = cast( - TestingLabelPrinterDriver, - registry.get_driver_instance('testing-label-printer'), - ) + machine = registry.get_machine(machine_config.pk) + self.assertIsNotNone(machine) + self.assertIsNotNone(machine.base_driver) + self.assertIsNotNone(machine.driver) - self.print_label = Mock() - driver_instance.print_label = self.print_label + return machine - self.print_labels = Mock(side_effect=driver_instance.print_labels) - driver_instance.print_labels = self.print_labels + def test_call_function(self): + """Test arbitrary function calls against a machine.""" + from machine.registry import call_machine_function + + machine = self.create_machine() + + with self.assertRaises(AttributeError): + call_machine_function(machine.pk, 'fake_function', custom_arg=123) + + result = call_machine_function(machine.pk, 'custom_func', x=3, y=4) + + self.assertEqual(result, 12) def test_print_label(self): """Test the print label method.""" plugin_ref = 'inventreelabelmachine' + machine = self.create_machine() + # setup the label app apps.get_app_config('report').create_default_labels() # type: ignore plg_registry.reload_plugins() + config = cast(PluginConfig, plg_registry.get_plugin(plugin_ref).plugin_config()) # type: ignore config.active = True config.save() @@ -257,53 +223,56 @@ class TestLabelPrinterMachineType(TestMachineRegistryMixin, InvenTreeAPITestCase url = reverse('api-label-print') - self.post( + with self.assertLogs('inventree', level='WARNING') as cm: + self.post( + url, + { + 'plugin': config.key, + 'items': [a.pk for a in parts], + 'template': template.pk, + 'machine': str(machine.pk), + 'driver_options': {'copies': '3', 'fake_option': 99}, + }, + expected_code=201, + ) + + # 4 entries for each printed label + self.assertEqual(len(cm.output), 10) + + # Check for expected messages + messages = [ + 'Printing Label: TestingLabelPrinterDriver', + f'machine: {machine.pk}', + f'label: {template.pk}', + f'item: {parts[0].pk}', + f'item: {parts[1].pk}', + 'options: copies: 3', + ] + + for message in messages: + result = False + for item in cm.records: + if message in str(item): + result = True + break + + self.assertTrue(result, f'Message not found: {message}') + + # test with non existing machine + response = self.post( url, { 'plugin': config.key, + 'machine': 'dummy-uuid-which-does-not-exist', + 'driver_options': {'copies': '1', 'test_option': '2'}, 'items': [a.pk for a in parts], 'template': template.pk, - 'machine': str(self.machine.pk), - 'driver_options': {'copies': '1', 'test_option': '2'}, - }, - expected_code=201, - ) - - # test the print labels method call - self.print_labels.assert_called_once() - self.assertEqual(self.print_labels.call_args.args[0], self.machine.machine) - self.assertEqual(self.print_labels.call_args.args[1], template) - - self.assertIn('printing_options', self.print_labels.call_args.kwargs) - self.assertEqual( - self.print_labels.call_args.kwargs['printing_options'], - {'copies': 1, 'test_option': 2}, - ) - - return - # TODO re-activate test - - # test the single print label method calls - self.assertEqual(self.print_label.call_count, 2) - self.assertEqual(self.print_label.call_args.args[0], self.machine.machine) - self.assertEqual(self.print_label.call_args.args[1], template) - self.assertEqual(self.print_label.call_args.args[2], parts[1]) - self.assertIn('printing_options', self.print_labels.call_args.kwargs) - self.assertEqual( - self.print_labels.call_args.kwargs['printing_options'], - {'copies': 1, 'test_option': 2}, - ) - - # test with non existing machine - self.post( - url, - { - 'machine': self.placeholder_uuid, - 'driver_options': {'copies': '1', 'test_option': '2'}, }, expected_code=400, ) + self.assertIn('is not a valid choice', str(response.data['machine'])) + class AdminTest(AdminTestCase): """Tests for the admin interface integration.""" diff --git a/src/backend/InvenTree/plugin/base/integration/MachineMixin.py b/src/backend/InvenTree/plugin/base/integration/MachineMixin.py new file mode 100644 index 0000000000..c7f0efcde4 --- /dev/null +++ b/src/backend/InvenTree/plugin/base/integration/MachineMixin.py @@ -0,0 +1,41 @@ +"""Plugin mixin for registering machine drivers.""" + +import structlog + +from machine.machine_type import BaseDriver, BaseMachineType +from plugin import PluginMixinEnum + +logger = structlog.get_logger('inventree') + + +class MachineDriverMixin: + """Mixin class for registering machine driver types. + + This mixin class can be used to register custom machine types or drivers. + + get_machine_types: + - Register a custom class of machine + - Returns a list of BaseMachineType objects + + get_machine_drivers: + - Register custom machine drivers for existing machine types + - Returns a list of BaseDriver objects + """ + + class MixinMeta: + """Meta class for MachineDriverMixin.""" + + MIXIN_NAME = 'MachineDriver' + + def __init__(self): + """Initialize the mixin and register it.""" + super().__init__() + self.add_mixin(PluginMixinEnum.MACHINE, True, __class__) + + def get_machine_types(self) -> list[BaseMachineType]: + """Register custom machine types.""" + return [] + + def get_machine_drivers(self) -> list[BaseDriver]: + """Register custom machine drivers.""" + return [] diff --git a/src/backend/InvenTree/plugin/builtin/integration/machine_types.py b/src/backend/InvenTree/plugin/builtin/integration/machine_types.py new file mode 100644 index 0000000000..33b7c28664 --- /dev/null +++ b/src/backend/InvenTree/plugin/builtin/integration/machine_types.py @@ -0,0 +1,28 @@ +"""Base plugin which defines the built-in machine types.""" + +from django.utils.translation import gettext_lazy as _ + +from machine.machine_type import BaseDriver, BaseMachineType +from plugin import InvenTreePlugin +from plugin.mixins import MachineDriverMixin + + +class InvenTreeMachineTypes(MachineDriverMixin, InvenTreePlugin): + """Plugin which provides built-in machine type definitions.""" + + NAME = 'InvenTreeMachines' + SLUG = 'inventree-machines' + TITLE = _('InvenTree Machines') + DESCRIPTION = _('Built-in machine types for InvenTree') + AUTHOR = _('InvenTree contributors') + VERSION = '1.0.0' + + def get_machine_types(self) -> list[BaseMachineType]: + """Return all built-in machine types.""" + from machine.machine_types.label_printer import LabelPrinterMachine + + return [LabelPrinterMachine] + + def get_machine_drivers(self) -> list[BaseDriver]: + """Return all built-in machine drivers.""" + return [] diff --git a/src/backend/InvenTree/plugin/mixins/__init__.py b/src/backend/InvenTree/plugin/mixins/__init__.py index 1e3f5e124c..0cd713294a 100644 --- a/src/backend/InvenTree/plugin/mixins/__init__.py +++ b/src/backend/InvenTree/plugin/mixins/__init__.py @@ -8,6 +8,7 @@ from plugin.base.integration.APICallMixin import APICallMixin from plugin.base.integration.AppMixin import AppMixin from plugin.base.integration.CurrencyExchangeMixin import CurrencyExchangeMixin from plugin.base.integration.DataExport import DataExportMixin +from plugin.base.integration.MachineMixin import MachineDriverMixin from plugin.base.integration.NavigationMixin import NavigationMixin from plugin.base.integration.NotificationMixin import NotificationMixin from plugin.base.integration.ReportMixin import ReportMixin @@ -32,6 +33,7 @@ __all__ = [ 'IconPackMixin', 'LabelPrintingMixin', 'LocateMixin', + 'MachineDriverMixin', 'MailMixin', 'NavigationMixin', 'NotificationMixin', diff --git a/src/backend/InvenTree/plugin/models.py b/src/backend/InvenTree/plugin/models.py index 2d6cc836ca..b040daa2fd 100644 --- a/src/backend/InvenTree/plugin/models.py +++ b/src/backend/InvenTree/plugin/models.py @@ -272,6 +272,10 @@ class PluginConfig(InvenTree.models.MetadataMixin, models.Model): offload_task( plugin.staticfiles.copy_plugin_static_files, self.key, group='plugin' ) + else: + offload_task( + plugin.staticfiles.clear_plugin_static_files, self.key, group='plugin' + ) class PluginSetting(common.models.BaseInvenTreeSetting): diff --git a/src/backend/InvenTree/plugin/plugin.py b/src/backend/InvenTree/plugin/plugin.py index df1e47c9a6..a32cc91e91 100644 --- a/src/backend/InvenTree/plugin/plugin.py +++ b/src/backend/InvenTree/plugin/plugin.py @@ -67,6 +67,7 @@ class PluginMixinEnum(StringEnum): ICON_PACK = 'icon_pack' LABELS = 'labels' LOCATE = 'locate' + MACHINE = 'machine' MAIL = 'mail' NAVIGATION = 'navigation' NOTIFICATION = 'notification' diff --git a/src/backend/InvenTree/plugin/registry.py b/src/backend/InvenTree/plugin/registry.py index 08bf5075cd..ca3fc9d743 100644 --- a/src/backend/InvenTree/plugin/registry.py +++ b/src/backend/InvenTree/plugin/registry.py @@ -94,6 +94,7 @@ class PluginsRegistry: 'bom-exporter', 'inventree-exporter', 'inventree-ui-notification', + 'inventree-machines', 'inventree-email-notification', 'inventreecurrencyexchange', 'inventreelabel', @@ -489,6 +490,9 @@ class PluginsRegistry: # If in TEST or DEBUG mode, load plugins from the 'samples' directory dirs.append('plugin.samples') + if settings.TESTING: + dirs.append('plugin.testing') + if settings.TESTING: custom_dirs = os.getenv('INVENTREE_PLUGIN_TEST_DIR', None) else: # pragma: no cover diff --git a/src/backend/InvenTree/plugin/samples/machines/__init__.py b/src/backend/InvenTree/plugin/samples/machines/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/backend/InvenTree/plugin/samples/machines/sample_printer.py b/src/backend/InvenTree/plugin/samples/machines/sample_printer.py new file mode 100644 index 0000000000..c10f728256 --- /dev/null +++ b/src/backend/InvenTree/plugin/samples/machines/sample_printer.py @@ -0,0 +1,63 @@ +"""Sample plugin for registering custom machines.""" + +from django.db import models + +import structlog + +from machine.machine_type import BaseDriver +from plugin import InvenTreePlugin +from plugin.machine import BaseMachineType +from plugin.machine.machine_types import LabelPrinterBaseDriver, LabelPrinterMachine +from plugin.mixins import MachineDriverMixin, SettingsMixin +from report.models import LabelTemplate + +logger = structlog.get_logger('inventree') + + +class SamplePrinterDriver(LabelPrinterBaseDriver): + """Sample printer driver.""" + + SLUG = 'sample-printer-driver' + NAME = 'Sample Label Printer Driver' + DESCRIPTION = 'Sample label printing driver for InvenTree' + + MACHINE_SETTINGS = { + 'CONNECTION': { + 'name': 'Connection String', + 'description': 'Custom string for connecting to the printer', + 'default': '123-xx123:8000', + } + } + + def init_machine(self, machine: BaseMachineType) -> None: + """Machine initialization hook.""" + + def print_label( + self, + machine: LabelPrinterMachine, + label: LabelTemplate, + item: models.Model, + **kwargs, + ) -> None: + """Send the label to the printer.""" + print('MOCK LABEL PRINTING:') + print('- machine:', machine) + print('- label:', label) + print('- item:', item) + + +class SamplePrinterMachine(MachineDriverMixin, SettingsMixin, InvenTreePlugin): + """A very simple example of a 'printer' machine plugin. + + This plugin class simply prints a message to the logger. + """ + + NAME = 'SamplePrinterMachine' + SLUG = 'sample-printer-machine-plugin' + TITLE = 'Sample dummy plugin for printing labels' + + VERSION = '0.1' + + def get_machine_drivers(self) -> list[BaseDriver]: + """Return a list of drivers registered by this plugin.""" + return [SamplePrinterDriver] diff --git a/src/backend/InvenTree/plugin/test_plugin.py b/src/backend/InvenTree/plugin/test_plugin.py index 4f5d0dfa51..d07b30ee8c 100644 --- a/src/backend/InvenTree/plugin/test_plugin.py +++ b/src/backend/InvenTree/plugin/test_plugin.py @@ -489,9 +489,12 @@ class RegistryTests(TestQueryMixin, PluginRegistryMixin, TestCase): # Start with a 'clean slate' PluginConfig.objects.all().delete() + # Change this value whenever a new mandatory plugin is added + N_MANDATORY_PLUGINS = 10 + registry.reload_plugins(full_reload=True, collect=True) mandatory = registry.MANDATORY_PLUGINS - self.assertEqual(len(mandatory), 9) + self.assertEqual(len(mandatory), N_MANDATORY_PLUGINS) # Check that the mandatory plugins are loaded self.assertEqual( diff --git a/src/backend/InvenTree/plugin/testing/__init__.py b/src/backend/InvenTree/plugin/testing/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/backend/InvenTree/plugin/testing/label_machines.py b/src/backend/InvenTree/plugin/testing/label_machines.py new file mode 100644 index 0000000000..084e0519d0 --- /dev/null +++ b/src/backend/InvenTree/plugin/testing/label_machines.py @@ -0,0 +1,99 @@ +"""Plugins for testing label machines.""" + +import structlog + +from machine.machine_type import BaseDriver +from plugin import InvenTreePlugin +from plugin.machine import BaseMachineType +from plugin.machine.machine_types import LabelPrinterBaseDriver +from plugin.mixins import MachineDriverMixin, SettingsMixin + +logger = structlog.get_logger('inventree') + + +class TestingLabelPrinterDriver(LabelPrinterBaseDriver): + """Test driver for label printing.""" + + SLUG = 'test-label-printer-api' + NAME = 'Test label printer' + DESCRIPTION = 'This is a test label printer driver for testing.' + + MACHINE_SETTINGS = { + 'TEST_SETTING': { + 'name': 'Test setting', + 'description': 'This is a test setting', + } + } + + def restart_machine(self, machine: BaseMachineType): + """Override restart_machine.""" + machine.set_status_text('Restarting...') + + def print_label(self, machine, label, item, **kwargs) -> None: + """Override print_label.""" + # Simply output some warning messages, + # which we can check for in the unit test + logger.warn('Printing Label: TestingLabelPrinterDriver') + logger.warn(f'machine: {machine.pk}') + logger.warn(f'label: {label.pk}') + logger.warn(f'item: {item.pk}') + + for k, v in kwargs['printing_options'].items(): + logger.warn(f'options: {k}: {v}') + + def custom_func(self, *args, x=0, y=0): + """A custom function for the driver.""" + return x * y + + +class TestingLabelPrinterDriverError1(LabelPrinterBaseDriver): + """Test driver for label printing.""" + + SLUG = 'test-label-printer-error' + NAME = 'Test label printer error' + DESCRIPTION = 'This is a test label printer driver for testing.' + + def print_label(self, *args, **kwargs) -> None: + """Override print_label.""" + + +class TestingLabelPrinterDriverError2(LabelPrinterBaseDriver): + """Test driver for label printing.""" + + SLUG = 'test-label-printer-error' + + NAME = 'Test label printer error' + DESCRIPTION = 'This is a test label printer driver for testing.' + + def print_label(self, *args, **kwargs) -> None: + """Override print_label.""" + + +class TestingLabelPrinterDriverNotImplemented(LabelPrinterBaseDriver): + """Test driver for label printing.""" + + SLUG = 'test-label-printer-not-implemented' + NAME = 'Test label printer error not implemented' + DESCRIPTION = 'This is a test label printer driver for testing.' + + +class LabelPrinterMachineTest(MachineDriverMixin, SettingsMixin, InvenTreePlugin): + """A test plugin for label printer machines. + + This plugin registers multiple driver types for unit testing. + """ + + NAME = 'LabelPrinterMachineTest' + SLUG = 'label-printer-test-plugin' + TITLE = 'Test plugin for label printer machines' + + VERSION = '0.1' + + def get_machine_drivers(self) -> list[BaseDriver]: + """Return a list of drivers registered by this plugin.""" + return [ + TestingLabelPrinterDriver, + TestingLabelPrinterDriverError1, + TestingLabelPrinterDriverError2, + TestingLabelPrinterDriverNotImplemented, + ] diff --git a/src/frontend/src/components/render/Generic.tsx b/src/frontend/src/components/render/Generic.tsx index a108ef1999..b828c8b00a 100644 --- a/src/frontend/src/components/render/Generic.tsx +++ b/src/frontend/src/components/render/Generic.tsx @@ -9,7 +9,7 @@ export function RenderProjectCode({ instance && ( ) ); diff --git a/src/frontend/src/components/render/Part.tsx b/src/frontend/src/components/render/Part.tsx index dc6ae39db8..261db11569 100644 --- a/src/frontend/src/components/render/Part.tsx +++ b/src/frontend/src/components/render/Part.tsx @@ -67,7 +67,7 @@ export function RenderPartCategory( const suffix: ReactNode = ( {instance.description}} + value={{instance.description}} position='bottom-end' zIndex={10000} icon='sitemap' diff --git a/src/frontend/src/components/render/Plugin.tsx b/src/frontend/src/components/render/Plugin.tsx index 4d1e4c5ba7..c402022b3f 100644 --- a/src/frontend/src/components/render/Plugin.tsx +++ b/src/frontend/src/components/render/Plugin.tsx @@ -12,8 +12,8 @@ export function RenderPlugin({ return ( {t`Inactive`} } /> diff --git a/src/frontend/src/components/render/Report.tsx b/src/frontend/src/components/render/Report.tsx index d380bad5d6..3f7e12d79d 100644 --- a/src/frontend/src/components/render/Report.tsx +++ b/src/frontend/src/components/render/Report.tsx @@ -8,10 +8,7 @@ export function RenderReportTemplate({ instance: any; }>): ReactNode { return ( - + ); } @@ -21,9 +18,6 @@ export function RenderLabelTemplate({ instance: any; }>): ReactNode { return ( - + ); } diff --git a/src/frontend/src/components/render/StatusRenderer.tsx b/src/frontend/src/components/render/StatusRenderer.tsx index 6c388ca93e..4a5823bffd 100644 --- a/src/frontend/src/components/render/StatusRenderer.tsx +++ b/src/frontend/src/components/render/StatusRenderer.tsx @@ -74,14 +74,16 @@ export function getStatusCodes( const statusCodeList = useGlobalStatusState.getState().status; if (statusCodeList === undefined) { - console.log('StatusRenderer: statusCodeList is undefined'); + console.warn('StatusRenderer: statusCodeList is undefined'); return null; } const statusCodes = statusCodeList[type]; if (statusCodes === undefined) { - console.log('StatusRenderer: statusCodes is undefined'); + console.warn( + `StatusRenderer: statusCodes is undefined for model '${type}'` + ); return null; } @@ -175,7 +177,9 @@ export const StatusRenderer = ({ } if (statusCodes === undefined || statusCodes === null) { - console.warn('StatusRenderer: statusCodes is undefined'); + console.warn( + `StatusRenderer: statusCodes is undefined for model '${type}'` + ); return null; } diff --git a/src/frontend/src/components/render/Stock.tsx b/src/frontend/src/components/render/Stock.tsx index f8908780d8..3304458e8e 100644 --- a/src/frontend/src/components/render/Stock.tsx +++ b/src/frontend/src/components/render/Stock.tsx @@ -29,7 +29,7 @@ export function RenderStockLocation( const suffix: ReactNode = ( {instance.description}} + value={{instance.description}} position='bottom-end' zIndex={10000} icon='sitemap' @@ -75,7 +75,7 @@ export function RenderStockLocationType({ } - secondary={`${instance.description} (${instance.location_count})`} + suffix={`${instance.description} (${instance.location_count})`} /> ); } diff --git a/src/frontend/src/components/render/User.tsx b/src/frontend/src/components/render/User.tsx index 6c545e6fe9..20317f3842 100644 --- a/src/frontend/src/components/render/User.tsx +++ b/src/frontend/src/components/render/User.tsx @@ -40,7 +40,7 @@ export function RenderUser({ } suffix={ - + {instance.first_name} {instance.last_name} diff --git a/src/frontend/src/pages/Index/Settings/AdminCenter/MachineManagementPanel.tsx b/src/frontend/src/pages/Index/Settings/AdminCenter/MachineManagementPanel.tsx index 71b9124c38..4ec74b75b1 100644 --- a/src/frontend/src/pages/Index/Settings/AdminCenter/MachineManagementPanel.tsx +++ b/src/frontend/src/pages/Index/Settings/AdminCenter/MachineManagementPanel.tsx @@ -18,7 +18,10 @@ import { apiUrl } from '@lib/functions/Api'; import { api } from '../../../../App'; import { StylishText } from '../../../../components/items/StylishText'; import { MachineListTable } from '../../../../tables/machine/MachineListTable'; -import { MachineTypeListTable } from '../../../../tables/machine/MachineTypeTable'; +import { + MachineDriverTable, + MachineTypeListTable +} from '../../../../tables/machine/MachineTypeTable'; interface MachineRegistryStatusI { registry_errors: { message: string }[]; @@ -42,7 +45,7 @@ export default function MachineManagementPanel() { }, [registryStatus]); return ( - + {t`Machines`} @@ -51,6 +54,14 @@ export default function MachineManagementPanel() { + + + {t`Machine Drivers`} + + + + + {t`Machine Types`} diff --git a/src/frontend/src/states/SettingsStates.tsx b/src/frontend/src/states/SettingsStates.tsx index 83ecbab187..a44445a4c3 100644 --- a/src/frontend/src/states/SettingsStates.tsx +++ b/src/frontend/src/states/SettingsStates.tsx @@ -210,7 +210,7 @@ export const createMachineSettingsState = ({ }: CreateMachineSettingStateProps) => { const pathParams: PathParams = { machine, config_type: configType }; - return createStore()((set, get) => ({ + const store = createStore()((set, get) => ({ settings: [], lookup: {}, loaded: false, @@ -255,6 +255,12 @@ export const createMachineSettingsState = ({ return isTrue(value); } })); + + useEffect(() => { + store.getState().fetchSettings(); + }, [machine, configType]); + + return store; }; /* diff --git a/src/frontend/src/tables/machine/MachineListTable.tsx b/src/frontend/src/tables/machine/MachineListTable.tsx index 285c07b31e..d57a63f6b7 100644 --- a/src/frontend/src/tables/machine/MachineListTable.tsx +++ b/src/frontend/src/tables/machine/MachineListTable.tsx @@ -1,6 +1,7 @@ import { t } from '@lingui/core/macro'; import { Accordion, + Alert, Badge, Box, Card, @@ -23,9 +24,11 @@ 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 type { TableColumn } from '@lib/types/Tables'; +import { RowDeleteAction, RowEditAction } from '@lib/index'; +import type { RowAction, TableColumn } from '@lib/types/Tables'; import type { InvenTreeTableProps } from '@lib/types/Tables'; import { Trans } from '@lingui/react/macro'; +import { api } from '../../App'; import { DeleteItemAction, EditItemAction, @@ -100,6 +103,32 @@ function MachineStatusIndicator({ machine }: Readonly<{ machine: MachineI }>) { ); } +/** + * Helper function to restart a machine with the provided ID + */ +function restartMachine({ + machinePk, + callback +}: { + machinePk: string; + callback?: () => void; +}) { + api + .post( + apiUrl(ApiEndpoints.machine_restart, undefined, { + machine: machinePk + }) + ) + .then(() => { + notifications.show({ + message: t`Machine restarted`, + color: 'green', + icon: + }); + callback?.(); + }); +} + export function useMachineTypeDriver({ includeTypes = true, includeDrivers = true @@ -192,26 +221,6 @@ function MachineDrawer({ refreshTable(); }, [refetch, refreshTable]); - const restartMachine = useCallback( - (machinePk: string) => { - api - .post( - apiUrl(ApiEndpoints.machine_restart, undefined, { - machine: machinePk - }) - ) - .then(() => { - refreshAll(); - notifications.show({ - message: t`Machine restarted`, - color: 'green', - icon: - }); - }); - }, - [refreshAll] - ); - const machineEditModal = useEditApiFormModal({ title: t`Edit machine`, url: ApiEndpoints.machine_list, @@ -232,7 +241,9 @@ function MachineDrawer({ url: ApiEndpoints.machine_list, pk: machinePk, preFormContent: ( - {t`Are you sure you want to remove the machine "${machine?.name ?? 'unknown'}"?`} + + {t`Are you sure you want to remove this machine?`} + ), onFormSuccess: () => { refreshTable(); @@ -245,7 +256,6 @@ function MachineDrawer({ {machineEditModal.modal} {machineDeleteModal.modal} - {machine && } @@ -279,7 +289,14 @@ function MachineDrawer({ indicator: machine?.restart_required ? { color: 'red' } : undefined, - onClick: () => machine && restartMachine(machine?.pk) + onClick: () => { + if (machine) { + restartMachine({ + machinePk: machine?.pk, + callback: refreshAll + }); + } + } } ]} /> @@ -487,9 +504,7 @@ export function MachineListTable({ accessor: 'status', sortable: false, render: (record) => { - const renderer = TableStatusRenderer( - `MachineStatus__${record.status_model}` as any - ); + const renderer = TableStatusRenderer(`${record.status_model}` as any); if (renderer && record.status !== -1) { return renderer(record); } @@ -552,6 +567,65 @@ export function MachineListTable({ } }); + const [selectedMachinePk, setSelectedMachinePk] = useState< + string | undefined + >(undefined); + + const deleteMachineForm = useDeleteApiFormModal({ + title: t`Delete Machine`, + successMessage: t`Machine successfully deleted.`, + url: ApiEndpoints.machine_list, + pk: selectedMachinePk, + preFormContent: ( + + {t`Are you sure you want to remove this machine?`} + + ), + table: table + }); + + const editMachineForm = useEditApiFormModal({ + title: t`Edit Machine`, + url: ApiEndpoints.machine_list, + pk: selectedMachinePk, + fields: { + name: {}, + active: {} + }, + table: table + }); + + const rowActions = useCallback((record: any): RowAction[] => { + return [ + { + icon: , + title: t`Restart Machine`, + onClick: () => { + restartMachine({ + machinePk: record.pk, + callback: () => { + table.refreshTable(); + } + }); + } + }, + RowEditAction({ + title: t`Edit machine`, + onClick: () => { + setSelectedMachinePk(record.pk); + editMachineForm.open(); + } + }), + RowDeleteAction({ + title: t`Delete Machine`, + onClick: () => { + setSelectedMachinePk(record.pk); + deleteMachineForm.open(); + } + }) + ]; + }, []); + const tableActions = useMemo(() => { return [ {createMachineForm.modal} + {editMachineForm.modal} + {deleteMachineForm.modal} {renderMachineDrawer && ( { + return [ + { + accessor: 'name', + title: t`Name` + }, + DescriptionColumn({}), + { + accessor: 'machine_type', + title: t`Driver Type` + }, + BooleanColumn({ + accessor: 'is_builtin', + title: t`Builtin driver` + }) + ]; + }, []); + + return ( + { + navigate(`${prefix ?? '.'}/driver-${machine.slug}/`); + }, + dataFormatter: (data: any) => { + if (machineType) { + return data.filter((d: any) => d.machine_type === machineType); + } + return data; + } + }} + /> + ); +} + function MachineTypeDrawer({ machineTypeSlug }: Readonly<{ machineTypeSlug: string }>) { @@ -63,23 +113,6 @@ function MachineTypeDrawer({ [machineTypes, machineTypeSlug] ); - const table = useTable('machineDrivers'); - - const machineDriverTableColumns = useMemo[]>( - () => [ - { - accessor: 'name', - title: t`Name` - }, - DescriptionColumn({}), - BooleanColumn({ - accessor: 'is_builtin', - title: t`Builtin driver` - }) - ], - [] - ); - return ( <> @@ -162,22 +195,7 @@ function MachineTypeDrawer({ - { - return data.filter( - (d: any) => d.machine_type === machineTypeSlug - ); - }, - enableDownload: false, - enableSearch: false, - onRowClick: (machine) => - navigate(`../driver-${machine.slug}/`) - }} - /> + @@ -379,7 +397,7 @@ export function MachineTypeListTable({ ...props, enableDownload: false, enableSearch: false, - onRowClick: (machine) => navigate(`type-${machine.slug}/`), + onRowClick: (machine) => navigate(`./type-${machine.slug}/`), params: { ...props.params } diff --git a/src/frontend/tests/pui_machines.spec.ts b/src/frontend/tests/pui_machines.spec.ts new file mode 100644 index 0000000000..2e268cd2d0 --- /dev/null +++ b/src/frontend/tests/pui_machines.spec.ts @@ -0,0 +1,113 @@ +import test from 'playwright/test'; +import { clickOnRowMenu, navigate } from './helpers'; +import { doCachedLogin } from './login'; +import { setPluginState } from './settings'; + +test('Machines - Admin Panel', async ({ browser }) => { + const page = await doCachedLogin(browser, { + username: 'admin', + password: 'inventree', + url: 'settings/admin/machine' + }); + + await page.getByRole('button', { name: 'Machines' }).click(); + await page.getByRole('button', { name: 'Machine Drivers' }).click(); + await page.getByRole('button', { name: 'Machine Types' }).click(); + await page.getByRole('button', { name: 'Machine Errors' }).click(); + + await page.getByText('There are no machine registry errors').waitFor(); +}); + +test('Machines - Activation', async ({ browser, request }) => { + const page = await doCachedLogin(browser, { + username: 'admin', + password: 'inventree', + url: 'settings/admin/machine' + }); + + // Ensure that the sample machine plugin is enabled + await setPluginState({ + request, + plugin: 'sample-printer-machine-plugin', + state: true + }); + + await page.reload(); + + await page.getByRole('button', { name: 'action-button-add-machine' }).click(); + await page + .getByRole('textbox', { name: 'text-field-name' }) + .fill('my-dummy-machine'); + await page + .getByRole('textbox', { name: 'choice-field-machine_type' }) + .fill('label'); + await page.getByRole('option', { name: 'Label Printer' }).click(); + + await page.getByRole('textbox', { name: 'choice-field-driver' }).click(); + await page + .getByRole('option', { name: 'Sample Label Printer Driver' }) + .click(); + await page.getByRole('button', { name: 'Submit' }).click(); + + // Creating the new machine opens the "machine drawer" + + // Check for "machine type" settings + await page.getByText('Scope the printer to a specific location').waitFor(); + + // Check for "machine driver" settings + await page.getByText('Custom string for connecting').waitFor(); + + // Edit the available setting + await page.getByRole('button', { name: 'edit-setting-CONNECTION' }).click(); + await page + .getByRole('textbox', { name: 'text-field-value' }) + .fill('a new value'); + await page.getByRole('button', { name: 'Submit' }).click(); + await page.getByText('Setting CONNECTION updated successfully').waitFor(); + + // Close the drawer + await page.getByRole('banner').getByRole('button').first().click(); + const cell = await page.getByRole('cell', { name: 'my-dummy-machine' }); + + // Let's restart the machine now + await clickOnRowMenu(cell); + await page.getByRole('menuitem', { name: 'Edit' }).waitFor(); + await page.getByRole('menuitem', { name: 'Restart' }).click(); + await page.getByText('Machine restarted').waitFor(); + + // Let's print something with the machine + await navigate(page, 'stock/location/1/stock-items'); + + await page.getByRole('checkbox', { name: 'Select all records' }).click(); + await page + .getByRole('tabpanel', { name: 'Stock Items' }) + .getByLabel('action-menu-printing-actions') + .click(); + await page + .getByRole('menuitem', { + name: 'action-menu-printing-actions-print-labels' + }) + .click(); + + await page.getByLabel('related-field-plugin').fill('machine'); + await page.getByText('InvenTreeLabelMachine').click(); + + await page + .getByRole('textbox', { name: 'choice-field-machine' }) + .fill('dummy'); + await page.getByRole('option', { name: 'my-dummy-machine' }).click(); + + await page + .getByRole('button', { name: 'Print', exact: true }) + .first() + .click(); + await page.getByText('Process completed successfully').waitFor(); + + await navigate(page, 'settings/admin/machine/'); + + // Finally, delete the machine configuration + await clickOnRowMenu(cell); + await page.getByRole('menuitem', { name: 'Delete' }).click(); + await page.getByRole('button', { name: 'Delete' }).click(); + await page.getByText('Machine successfully deleted.').waitFor(); +});