2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-09-13 14:11:37 +00:00

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
This commit is contained in:
Oliver
2025-08-20 23:00:39 +10:00
committed by GitHub
parent ed31503d3b
commit bd9c52eeaf
40 changed files with 1095 additions and 425 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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: []

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -36,6 +36,7 @@ class MachineConfig(AppConfig):
try:
logger.info('Loading InvenTree machines')
if not registry.is_ready:
registry.initialize(main=isInMainThread())
except (OperationalError, ProgrammingError):
# Database might not yet be ready

View File

@@ -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,33 +118,41 @@ 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 plugin in plugin_registry.with_mixin(PluginMixinEnum.MACHINE):
try:
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,
)
for machine_type in discovered_machine_types:
continue
try:
machine_type.validate()
except NotImplementedError as error:
@@ -75,32 +161,44 @@ class MachineRegistry(
if machine_type.SLUG in machine_types:
self.handle_error(
ValueError(f"Cannot re-register machine type '{machine_type.SLUG}'")
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',
)
self.handle_error(error)
self.machine_types = machine_types
self.base_drivers = base_drivers
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 plugin in plugin_registry.with_mixin(PluginMixinEnum.MACHINE):
try:
for driver in plugin.get_machine_drivers():
if not issubclass(driver, BaseDriver):
logger.error(
'INVE-E12: Plugin %s returned invalid driver type',
plugin.slug,
)
for driver in discovered_drivers:
# skip discovered drivers that define a base driver for a machine type
if driver in self.base_drivers:
continue
try:
@@ -116,11 +214,17 @@ class MachineRegistry(
continue
drivers[driver.SLUG] = driver
except Exception as error:
log_error(
'discover_drivers', plugin=plugin.slug, scope='MachineRegistry'
)
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
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)

View File

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

View File

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

View File

@@ -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')
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(self.machine.pk),
'driver_options': {'copies': '1', 'test_option': '2'},
'machine': str(machine.pk),
'driver_options': {'copies': '3', 'fake_option': 99},
},
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)
# 4 entries for each printed label
self.assertEqual(len(cm.output), 10)
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},
)
# 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',
]
return
# TODO re-activate test
for message in messages:
result = False
for item in cm.records:
if message in str(item):
result = True
break
# 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},
)
self.assertTrue(result, f'Message not found: {message}')
# test with non existing machine
self.post(
response = self.post(
url,
{
'machine': self.placeholder_uuid,
'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,
},
expected_code=400,
)
self.assertIn('is not a valid choice', str(response.data['machine']))
class AdminTest(AdminTestCase):
"""Tests for the admin interface integration."""

View File

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

View File

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

View File

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

View File

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

View File

@@ -67,6 +67,7 @@ class PluginMixinEnum(StringEnum):
ICON_PACK = 'icon_pack'
LABELS = 'labels'
LOCATE = 'locate'
MACHINE = 'machine'
MAIL = 'mail'
NAVIGATION = 'navigation'
NOTIFICATION = 'notification'

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,7 +9,7 @@ export function RenderProjectCode({
instance && (
<RenderInlineModel
primary={instance.code}
secondary={instance.description}
suffix={instance.description}
/>
)
);

View File

@@ -67,7 +67,7 @@ export function RenderPartCategory(
const suffix: ReactNode = (
<Group gap='xs'>
<TableHoverCard
value={<Text size='sm'>{instance.description}</Text>}
value={<Text size='xs'>{instance.description}</Text>}
position='bottom-end'
zIndex={10000}
icon='sitemap'

View File

@@ -12,8 +12,8 @@ export function RenderPlugin({
return (
<RenderInlineModel
primary={instance.name}
secondary={instance.meta?.description}
suffix={
suffix={instance.meta?.description}
secondary={
!instance.active && <Badge size='sm' color='red'>{t`Inactive`}</Badge>
}
/>

View File

@@ -8,10 +8,7 @@ export function RenderReportTemplate({
instance: any;
}>): ReactNode {
return (
<RenderInlineModel
primary={instance.name}
secondary={instance.description}
/>
<RenderInlineModel primary={instance.name} suffix={instance.description} />
);
}
@@ -21,9 +18,6 @@ export function RenderLabelTemplate({
instance: any;
}>): ReactNode {
return (
<RenderInlineModel
primary={instance.name}
secondary={instance.description}
/>
<RenderInlineModel primary={instance.name} suffix={instance.description} />
);
}

View File

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

View File

@@ -29,7 +29,7 @@ export function RenderStockLocation(
const suffix: ReactNode = (
<Group gap='xs'>
<TableHoverCard
value={<Text size='sm'>{instance.description}</Text>}
value={<Text size='xs'>{instance.description}</Text>}
position='bottom-end'
zIndex={10000}
icon='sitemap'
@@ -75,7 +75,7 @@ export function RenderStockLocationType({
<RenderInlineModel
primary={instance.name}
prefix={instance.icon && <ApiIcon name={instance.icon} />}
secondary={`${instance.description} (${instance.location_count})`}
suffix={`${instance.description} (${instance.location_count})`}
/>
);
}

View File

@@ -40,7 +40,7 @@ export function RenderUser({
}
suffix={
<Group gap='xs'>
<Text size='sm'>
<Text size='xs'>
{instance.first_name} {instance.last_name}
</Text>
<IconUser size={16} />

View File

@@ -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 (
<Accordion multiple defaultValue={['machinelist', 'machinetypes']}>
<Accordion multiple defaultValue={['machinelist']}>
<Accordion.Item value='machinelist'>
<Accordion.Control>
<StylishText size='lg'>{t`Machines`}</StylishText>
@@ -51,6 +54,14 @@ export default function MachineManagementPanel() {
<MachineListTable props={{}} />
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value='drivertypes'>
<Accordion.Control>
<StylishText size='lg'>{t`Machine Drivers`}</StylishText>
</Accordion.Control>
<Accordion.Panel>
<MachineDriverTable />
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value='machinetypes'>
<Accordion.Control>
<StylishText size='lg'>{t`Machine Types`}</StylishText>

View File

@@ -210,7 +210,7 @@ export const createMachineSettingsState = ({
}: CreateMachineSettingStateProps) => {
const pathParams: PathParams = { machine, config_type: configType };
return createStore<SettingsStateProps>()((set, get) => ({
const store = createStore<SettingsStateProps>()((set, get) => ({
settings: [],
lookup: {},
loaded: false,
@@ -255,6 +255,12 @@ export const createMachineSettingsState = ({
return isTrue(value);
}
}));
useEffect(() => {
store.getState().fetchSettings();
}, [machine, configType]);
return store;
};
/*

View File

@@ -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: <IconCheck size='1rem' />
});
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: <IconCheck size='1rem' />
});
});
},
[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: (
<Text>{t`Are you sure you want to remove the machine "${machine?.name ?? 'unknown'}"?`}</Text>
<Alert color='red'>
{t`Are you sure you want to remove this machine?`}
</Alert>
),
onFormSuccess: () => {
refreshTable();
@@ -245,7 +256,6 @@ function MachineDrawer({
<Stack gap='xs'>
{machineEditModal.modal}
{machineDeleteModal.modal}
<Group justify='space-between'>
<Group>
{machine && <MachineStatusIndicator machine={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: (
<Alert color='red'>
{t`Are you sure you want to remove this machine?`}
</Alert>
),
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: <IconRefresh />,
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 [
<AddItemButton
@@ -568,6 +642,8 @@ export function MachineListTable({
return (
<>
{createMachineForm.modal}
{editMachineForm.modal}
{deleteMachineForm.modal}
{renderMachineDrawer && (
<DetailDrawer
title={t`Machine Detail`}
@@ -596,7 +672,8 @@ export function MachineListTable({
? `machine-${machine.pk}/`
: `../machine-${machine.pk}/`
),
tableActions,
rowActions: rowActions,
tableActions: tableActions,
params: {
...props.params
},

View File

@@ -50,6 +50,56 @@ export interface MachineDriverI {
driver_errors: string[];
}
export function MachineDriverTable({
machineType,
prefix
}: {
machineType?: string;
prefix?: string;
}) {
const navigate = useNavigate();
const table = useTable('machine-drivers');
const tableColumns: TableColumn[] = useMemo(() => {
return [
{
accessor: 'name',
title: t`Name`
},
DescriptionColumn({}),
{
accessor: 'machine_type',
title: t`Driver Type`
},
BooleanColumn({
accessor: 'is_builtin',
title: t`Builtin driver`
})
];
}, []);
return (
<InvenTreeTable
url={apiUrl(ApiEndpoints.machine_driver_list)}
tableState={table}
columns={tableColumns}
props={{
enableDownload: false,
enableSearch: false,
onRowClick: (machine) => {
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<TableColumn<MachineDriverI>[]>(
() => [
{
accessor: 'name',
title: t`Name`
},
DescriptionColumn({}),
BooleanColumn({
accessor: 'is_builtin',
title: t`Builtin driver`
})
],
[]
);
return (
<>
<Stack>
@@ -162,22 +195,7 @@ function MachineTypeDrawer({
</Accordion.Control>
<Accordion.Panel>
<Card withBorder>
<InvenTreeTable
url={apiUrl(ApiEndpoints.machine_driver_list)}
tableState={table}
columns={machineDriverTableColumns}
props={{
dataFormatter: (data: any) => {
return data.filter(
(d: any) => d.machine_type === machineTypeSlug
);
},
enableDownload: false,
enableSearch: false,
onRowClick: (machine) =>
navigate(`../driver-${machine.slug}/`)
}}
/>
<MachineDriverTable machineType={machineTypeSlug} prefix='..' />
</Card>
</Accordion.Panel>
</Accordion.Item>
@@ -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
}

View File

@@ -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();
});