mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-10-30 20:55:42 +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:
		| @@ -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') | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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') | ||||
|   | ||||
| @@ -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) | ||||
|   | ||||
| @@ -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] | ||||
|   | ||||
| @@ -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( | ||||
|   | ||||
| @@ -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.""" | ||||
|   | ||||
| @@ -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 [] | ||||
| @@ -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 [] | ||||
| @@ -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', | ||||
|   | ||||
| @@ -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): | ||||
|   | ||||
| @@ -67,6 +67,7 @@ class PluginMixinEnum(StringEnum): | ||||
|     ICON_PACK = 'icon_pack' | ||||
|     LABELS = 'labels' | ||||
|     LOCATE = 'locate' | ||||
|     MACHINE = 'machine' | ||||
|     MAIL = 'mail' | ||||
|     NAVIGATION = 'navigation' | ||||
|     NOTIFICATION = 'notification' | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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] | ||||
| @@ -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( | ||||
|   | ||||
							
								
								
									
										0
									
								
								src/backend/InvenTree/plugin/testing/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								src/backend/InvenTree/plugin/testing/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										99
									
								
								src/backend/InvenTree/plugin/testing/label_machines.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										99
									
								
								src/backend/InvenTree/plugin/testing/label_machines.py
									
									
									
									
									
										Normal 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, | ||||
|         ] | ||||
		Reference in New Issue
	
	Block a user