2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-09-14 06:31:27 +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

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

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,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)

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')
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."""

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