mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Fix machine request pickeling and settings (#6772)
* Fix machine request pickeling * fix precommit * fix: shared state between workers and main thread for machine registry * remove last usage of legacy PUI form framework to fix machine edit/delete modal * reset cache before initialization * update documentation * fix: invalidating cache * implement machine registry hash to check if a reload is required * trigger: ci * fix: request bug * fix: test * trigger: ci * add clear errors and improve restart hook * auto initialize not initialized machines when changing active state * fix: tests
This commit is contained in:
parent
767b76314e
commit
23a394d740
@ -6,6 +6,9 @@ title: Machines
|
||||
|
||||
InvenTree has a builtin machine registry. There are different machine types available where each type can have different drivers. Drivers and even custom machine types can be provided by plugins.
|
||||
|
||||
!!! info "Requires Redis"
|
||||
If the machines features is used in production setup using workers, a shared [redis cache](../../start/docker.md#redis-cache) is required to function properly.
|
||||
|
||||
### Registry
|
||||
|
||||
The machine registry is the main component which gets initialized on server start and manages all configured machines.
|
||||
@ -21,6 +24,13 @@ The machine registry initialization process can be divided into three stages:
|
||||
2. The driver.init_driver function is called for each used driver
|
||||
3. The machine.initialize function is called for each machine, which calls the driver.init_machine function for each machine, then the machine.initialized state is set to true
|
||||
|
||||
#### Production setup (with a worker)
|
||||
|
||||
If a worker is connected, there exist multiple instances of the machine registry (one in each worker thread and one in the main thread) due to the nature of how python handles state in different processes. Therefore the machine instances and drivers are instantiated multiple times (The `__init__` method is called multiple times). But the init functions and update hooks (e.g. `init_machine`) are only called once from the main process.
|
||||
|
||||
The registry, driver and machine state (e.g. machine status codes, errors, ...) is stored in the cache. Therefore a shared redis cache is needed. (The local in-memory cache which is used by default is not capable to cache across multiple processes)
|
||||
|
||||
|
||||
### Machine types
|
||||
|
||||
Each machine type can provide a different type of connection functionality between inventree and a physical machine. These machine types are already built into InvenTree.
|
||||
@ -86,6 +96,7 @@ The machine type class gets instantiated for each machine on server startup and
|
||||
- update
|
||||
- restart
|
||||
- handle_error
|
||||
- clear_errors
|
||||
- get_setting
|
||||
- set_setting
|
||||
- check_setting
|
||||
|
@ -2,8 +2,10 @@
|
||||
|
||||
import inspect
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
|
||||
from plugin import registry as plg_registry
|
||||
|
||||
@ -104,3 +106,37 @@ class ClassProviderMixin:
|
||||
except ValueError:
|
||||
# Path(...).relative_to throws an ValueError if its not relative to the InvenTree source base dir
|
||||
return False
|
||||
|
||||
|
||||
def get_shared_class_instance_state_mixin(get_state_key: Callable[[type], str]):
|
||||
"""Get a mixin class that provides shared state for classes across the main application and worker.
|
||||
|
||||
Arguments:
|
||||
get_state_key: A function that returns the key for the shared state when given a class instance.
|
||||
"""
|
||||
|
||||
class SharedClassStateMixinClass:
|
||||
"""Mixin to provide shared state for classes across the main application and worker."""
|
||||
|
||||
def set_shared_state(self, key: str, value: Any):
|
||||
"""Set a shared state value for this machine.
|
||||
|
||||
Arguments:
|
||||
key: The key for the shared state
|
||||
value: The value to set
|
||||
"""
|
||||
cache.set(self._get_key(key), value, timeout=None)
|
||||
|
||||
def get_shared_state(self, key: str, default=None):
|
||||
"""Get a shared state value for this machine.
|
||||
|
||||
Arguments:
|
||||
key: The key for the shared state
|
||||
"""
|
||||
return cache.get(self._get_key(key)) or default
|
||||
|
||||
def _get_key(self, key: str):
|
||||
"""Get the key for this class instance."""
|
||||
return f'{get_state_key(self)}:{key}'
|
||||
|
||||
return SharedClassStateMixinClass
|
||||
|
@ -26,7 +26,6 @@ class MachineConfig(AppConfig):
|
||||
if (
|
||||
not canAppAccessDatabase(allow_test=True)
|
||||
or not isPluginRegistryLoaded()
|
||||
or not isInMainThread()
|
||||
or isRunningMigrations()
|
||||
or isImportingData()
|
||||
):
|
||||
@ -37,7 +36,7 @@ class MachineConfig(AppConfig):
|
||||
|
||||
try:
|
||||
logger.info('Loading InvenTree machines')
|
||||
registry.initialize()
|
||||
registry.initialize(main=isInMainThread())
|
||||
except (OperationalError, ProgrammingError):
|
||||
# Database might not yet be ready
|
||||
logger.warn('Database was not ready for initializing machines')
|
||||
|
@ -3,7 +3,11 @@
|
||||
from typing import TYPE_CHECKING, Any, Literal, Union
|
||||
|
||||
from generic.states import StatusCode
|
||||
from InvenTree.helpers_mixin import ClassProviderMixin, ClassValidationMixin
|
||||
from InvenTree.helpers_mixin import (
|
||||
ClassProviderMixin,
|
||||
ClassValidationMixin,
|
||||
get_shared_class_instance_state_mixin,
|
||||
)
|
||||
|
||||
# Import only for typechecking, otherwise this throws cyclic import errors
|
||||
if TYPE_CHECKING:
|
||||
@ -44,7 +48,11 @@ class MachineStatus(StatusCode):
|
||||
"""
|
||||
|
||||
|
||||
class BaseDriver(ClassValidationMixin, ClassProviderMixin):
|
||||
class BaseDriver(
|
||||
ClassValidationMixin,
|
||||
ClassProviderMixin,
|
||||
get_shared_class_instance_state_mixin(lambda x: f'machine:driver:{x.SLUG}'),
|
||||
):
|
||||
"""Base class for all machine drivers.
|
||||
|
||||
Attributes:
|
||||
@ -69,8 +77,6 @@ class BaseDriver(ClassValidationMixin, ClassProviderMixin):
|
||||
"""Base driver __init__ method."""
|
||||
super().__init__()
|
||||
|
||||
self.errors: list[Union[str, Exception]] = []
|
||||
|
||||
def init_driver(self):
|
||||
"""This method gets called after all machines are created and can be used to initialize the driver.
|
||||
|
||||
@ -133,10 +139,20 @@ class BaseDriver(ClassValidationMixin, ClassProviderMixin):
|
||||
Arguments:
|
||||
error: Exception or string
|
||||
"""
|
||||
self.errors.append(error)
|
||||
self.set_shared_state('errors', self.errors + [error])
|
||||
|
||||
# --- state getters/setters
|
||||
@property
|
||||
def errors(self) -> list[Union[str, Exception]]:
|
||||
"""List of driver errors."""
|
||||
return self.get_shared_state('errors', [])
|
||||
|
||||
|
||||
class BaseMachineType(ClassValidationMixin, ClassProviderMixin):
|
||||
class BaseMachineType(
|
||||
ClassValidationMixin,
|
||||
ClassProviderMixin,
|
||||
get_shared_class_instance_state_mixin(lambda x: f'machine:machine:{x.pk}'),
|
||||
):
|
||||
"""Base class for machine types.
|
||||
|
||||
Attributes:
|
||||
@ -178,12 +194,6 @@ class BaseMachineType(ClassValidationMixin, ClassProviderMixin):
|
||||
from machine import registry
|
||||
from machine.models import MachineSetting
|
||||
|
||||
self.errors: list[Union[str, Exception]] = []
|
||||
self.initialized = False
|
||||
|
||||
self.status = self.default_machine_status
|
||||
self.status_text: str = ''
|
||||
|
||||
self.pk = machine_config.pk
|
||||
self.driver = registry.get_driver_instance(machine_config.driver)
|
||||
|
||||
@ -208,8 +218,6 @@ class BaseMachineType(ClassValidationMixin, ClassProviderMixin):
|
||||
(self.driver_settings, MachineSetting.ConfigType.DRIVER),
|
||||
]
|
||||
|
||||
self.restart_required = False
|
||||
|
||||
def __str__(self):
|
||||
"""String representation of a machine."""
|
||||
return f'{self.name}'
|
||||
@ -272,16 +280,32 @@ class BaseMachineType(ClassValidationMixin, ClassProviderMixin):
|
||||
|
||||
try:
|
||||
self.driver.update_machine(old_state, self)
|
||||
|
||||
# check if the active state has changed and initialize the machine if necessary
|
||||
if old_state['active'] != self.active:
|
||||
if self.initialized is False and self.active is True:
|
||||
self.initialize()
|
||||
elif self.initialized is True and self.active is False:
|
||||
self.initialized = False
|
||||
except Exception as e:
|
||||
self.handle_error(e)
|
||||
|
||||
def restart(self):
|
||||
"""Machine restart function, can be used to manually restart the machine from the admin ui."""
|
||||
"""Machine restart function, can be used to manually restart the machine from the admin ui.
|
||||
|
||||
This will first reset the machines state (errors, status, status_text) and then call the drivers restart function.
|
||||
"""
|
||||
if self.driver is None:
|
||||
return
|
||||
|
||||
try:
|
||||
# reset the machine state
|
||||
self.restart_required = False
|
||||
self.reset_errors()
|
||||
self.set_status(self.default_machine_status)
|
||||
self.set_status_text('')
|
||||
|
||||
# call the driver restart function
|
||||
self.driver.restart_machine(self)
|
||||
except Exception as e:
|
||||
self.handle_error(e)
|
||||
@ -293,7 +317,11 @@ class BaseMachineType(ClassValidationMixin, ClassProviderMixin):
|
||||
Arguments:
|
||||
error: Exception or string
|
||||
"""
|
||||
self.errors.append(error)
|
||||
self.set_shared_state('errors', self.errors + [error])
|
||||
|
||||
def reset_errors(self):
|
||||
"""Helper function for resetting the error list for a machine."""
|
||||
self.set_shared_state('errors', [])
|
||||
|
||||
def get_setting(
|
||||
self, key: str, config_type_str: Literal['M', 'D'], cache: bool = False
|
||||
@ -364,7 +392,7 @@ class BaseMachineType(ClassValidationMixin, ClassProviderMixin):
|
||||
Arguments:
|
||||
status: The new MachineStatus code to set
|
||||
"""
|
||||
self.status = status
|
||||
self.set_shared_state('status', status.value)
|
||||
|
||||
def set_status_text(self, status_text: str):
|
||||
"""Set the machine status text. It can be any arbitrary text.
|
||||
@ -372,4 +400,39 @@ class BaseMachineType(ClassValidationMixin, ClassProviderMixin):
|
||||
Arguments:
|
||||
status_text: The new status text to set
|
||||
"""
|
||||
self.status_text = status_text
|
||||
self.set_shared_state('status_text', status_text)
|
||||
|
||||
# --- state getters/setters
|
||||
@property
|
||||
def initialized(self) -> bool:
|
||||
"""Initialized state of the machine."""
|
||||
return self.get_shared_state('initialized', False)
|
||||
|
||||
@initialized.setter
|
||||
def initialized(self, value: bool):
|
||||
self.set_shared_state('initialized', value)
|
||||
|
||||
@property
|
||||
def restart_required(self) -> bool:
|
||||
"""Restart required state of the machine."""
|
||||
return self.get_shared_state('restart_required', False)
|
||||
|
||||
@restart_required.setter
|
||||
def restart_required(self, value: bool):
|
||||
self.set_shared_state('restart_required', value)
|
||||
|
||||
@property
|
||||
def errors(self) -> list[Union[str, Exception]]:
|
||||
"""List of machine errors."""
|
||||
return self.get_shared_state('errors', [])
|
||||
|
||||
@property
|
||||
def status(self) -> MachineStatus:
|
||||
"""Machine status code."""
|
||||
status_code = self.get_shared_state('status', self.default_machine_status.value)
|
||||
return self.MACHINE_STATUS(status_code)
|
||||
|
||||
@property
|
||||
def status_text(self) -> str:
|
||||
"""Machine status text."""
|
||||
return self.get_shared_state('status_text', '')
|
||||
|
@ -2,9 +2,10 @@
|
||||
|
||||
from typing import Union, cast
|
||||
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.db import models
|
||||
from django.db.models.query import QuerySet
|
||||
from django.http import HttpResponse, JsonResponse
|
||||
from django.http import HttpRequest, HttpResponse, JsonResponse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from PIL.Image import Image
|
||||
@ -34,7 +35,6 @@ class LabelPrinterBaseDriver(BaseDriver):
|
||||
machine: 'LabelPrinterMachine',
|
||||
label: LabelTemplate,
|
||||
item: models.Model,
|
||||
request: Request,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
"""Print a single label with the provided template and item.
|
||||
@ -43,7 +43,6 @@ class LabelPrinterBaseDriver(BaseDriver):
|
||||
machine: The LabelPrintingMachine instance that should be used for printing
|
||||
label: The LabelTemplate object to use for printing
|
||||
item: The database item to print (e.g. StockItem instance)
|
||||
request: The HTTP request object which triggered this print job
|
||||
|
||||
Keyword Arguments:
|
||||
printing_options (dict): The printing options set for this print job defined in the PrintingOptionsSerializer
|
||||
@ -57,8 +56,7 @@ class LabelPrinterBaseDriver(BaseDriver):
|
||||
self,
|
||||
machine: 'LabelPrinterMachine',
|
||||
label: LabelTemplate,
|
||||
items: QuerySet,
|
||||
request: Request,
|
||||
items: QuerySet[models.Model],
|
||||
**kwargs,
|
||||
) -> Union[None, JsonResponse]:
|
||||
"""Print one or more labels with the provided template and items.
|
||||
@ -67,7 +65,6 @@ class LabelPrinterBaseDriver(BaseDriver):
|
||||
machine: The LabelPrintingMachine instance that should be used for printing
|
||||
label: The LabelTemplate object to use for printing
|
||||
items: The list of database items to print (e.g. StockItem instances)
|
||||
request: The HTTP request object which triggered this print job
|
||||
|
||||
Keyword Arguments:
|
||||
printing_options (dict): The printing options set for this print job defined in the PrintingOptionsSerializer
|
||||
@ -81,7 +78,7 @@ class LabelPrinterBaseDriver(BaseDriver):
|
||||
but this can be overridden by the particular driver.
|
||||
"""
|
||||
for item in items:
|
||||
self.print_label(machine, label, item, request, **kwargs)
|
||||
self.print_label(machine, label, item, **kwargs)
|
||||
|
||||
def get_printers(
|
||||
self, label: LabelTemplate, items: QuerySet, **kwargs
|
||||
@ -123,56 +120,50 @@ class LabelPrinterBaseDriver(BaseDriver):
|
||||
return cast(LabelPrintingMixin, plg)
|
||||
|
||||
def render_to_pdf(
|
||||
self, label: LabelTemplate, item: models.Model, request: Request, **kwargs
|
||||
self, label: LabelTemplate, item: models.Model, **kwargs
|
||||
) -> HttpResponse:
|
||||
"""Helper method to render a label to PDF format for a specific item.
|
||||
|
||||
Arguments:
|
||||
label: The LabelTemplate object to render
|
||||
item: The item to render the label with
|
||||
request: The HTTP request object which triggered this print job
|
||||
"""
|
||||
response = self.machine_plugin.render_to_pdf(label, item, request, **kwargs)
|
||||
return response
|
||||
request = self._get_dummy_request()
|
||||
return self.machine_plugin.render_to_pdf(label, item, request, **kwargs)
|
||||
|
||||
def render_to_pdf_data(
|
||||
self, label: LabelTemplate, item: models.Model, request: Request, **kwargs
|
||||
self, label: LabelTemplate, item: models.Model, **kwargs
|
||||
) -> bytes:
|
||||
"""Helper method to render a label to PDF and return it as bytes for a specific item.
|
||||
|
||||
Arguments:
|
||||
label: The LabelTemplate object to render
|
||||
item: The item to render the label with
|
||||
request: The HTTP request object which triggered this print job
|
||||
"""
|
||||
return (
|
||||
self.render_to_pdf(label, item, request, **kwargs)
|
||||
self.render_to_pdf(label, item, **kwargs)
|
||||
.get_document() # type: ignore
|
||||
.write_pdf()
|
||||
)
|
||||
|
||||
def render_to_html(
|
||||
self, label: LabelTemplate, item: models.Model, request: Request, **kwargs
|
||||
) -> str:
|
||||
def render_to_html(self, label: LabelTemplate, item: models.Model, **kwargs) -> str:
|
||||
"""Helper method to render a label to HTML format for a specific item.
|
||||
|
||||
Arguments:
|
||||
label: The LabelTemplate object to render
|
||||
item: The item to render the label with
|
||||
request: The HTTP request object which triggered this print job
|
||||
"""
|
||||
html = self.machine_plugin.render_to_html(label, item, request, **kwargs)
|
||||
return html
|
||||
request = self._get_dummy_request()
|
||||
return self.machine_plugin.render_to_html(label, item, request, **kwargs)
|
||||
|
||||
def render_to_png(
|
||||
self, label: LabelTemplate, item: models.Model, request: Request, **kwargs
|
||||
) -> Image:
|
||||
self, label: LabelTemplate, item: models.Model, **kwargs
|
||||
) -> Union[Image, None]:
|
||||
"""Helper method to render a label to PNG format for a specific item.
|
||||
|
||||
Arguments:
|
||||
label: The LabelTemplate object to render
|
||||
item: The item to render the label with
|
||||
request: The HTTP request object which triggered this print job
|
||||
|
||||
Keyword Arguments:
|
||||
pdf_data (bytes): The pdf document as bytes (optional)
|
||||
@ -182,8 +173,20 @@ class LabelPrinterBaseDriver(BaseDriver):
|
||||
pdf2image_kwargs (dict): Additional keyword arguments to pass to the
|
||||
[`pdf2image.convert_from_bytes`](https://pdf2image.readthedocs.io/en/latest/reference.html#pdf2image.pdf2image.convert_from_bytes) method (optional)
|
||||
"""
|
||||
png = self.machine_plugin.render_to_png(label, item, request, **kwargs)
|
||||
return png
|
||||
request = self._get_dummy_request()
|
||||
return self.machine_plugin.render_to_png(label, item, request, **kwargs)
|
||||
|
||||
def _get_dummy_request(self):
|
||||
"""Return a dummy request object to it work with legacy code.
|
||||
|
||||
Note: this is a private method and can be removed at anytime
|
||||
"""
|
||||
r = HttpRequest()
|
||||
r.META['SERVER_PORT'] = '80'
|
||||
r.META['SERVER_NAME'] = 'localhost'
|
||||
r.user = AnonymousUser()
|
||||
|
||||
return r
|
||||
|
||||
required_overrides = [[print_label, print_labels]]
|
||||
|
||||
@ -229,6 +232,7 @@ class LabelPrinterStatus(MachineStatus):
|
||||
UNKNOWN = 101, _('Unknown'), 'secondary'
|
||||
PRINTING = 110, _('Printing'), 'primary'
|
||||
NO_MEDIA = 301, _('No media'), 'warning'
|
||||
PAPER_JAM = 302, _('Paper jam'), 'warning'
|
||||
DISCONNECTED = 400, _('Disconnected'), 'danger'
|
||||
|
||||
|
||||
|
@ -1,15 +1,20 @@
|
||||
"""Machine registry."""
|
||||
|
||||
import logging
|
||||
from typing import Union
|
||||
from typing import Union, cast
|
||||
from uuid import UUID
|
||||
|
||||
from django.core.cache import cache
|
||||
|
||||
from InvenTree.helpers_mixin import get_shared_class_instance_state_mixin
|
||||
from machine.machine_type import BaseDriver, BaseMachineType
|
||||
|
||||
logger = logging.getLogger('inventree')
|
||||
|
||||
|
||||
class MachineRegistry:
|
||||
class MachineRegistry(
|
||||
get_shared_class_instance_state_mixin(lambda _x: f'machine:registry')
|
||||
):
|
||||
"""Machine registry class."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
@ -23,17 +28,27 @@ class MachineRegistry:
|
||||
self.machines: dict[str, BaseMachineType] = {}
|
||||
|
||||
self.base_drivers: list[type[BaseDriver]] = []
|
||||
self.errors: list[Union[str, Exception]] = []
|
||||
|
||||
self._hash = None
|
||||
|
||||
@property
|
||||
def errors(self) -> list[Union[str, Exception]]:
|
||||
"""List of registry errors."""
|
||||
return cast(list[Union[str, Exception]], self.get_shared_state('errors', []))
|
||||
|
||||
def handle_error(self, error: Union[Exception, str]):
|
||||
"""Helper function for capturing errors with the machine registry."""
|
||||
self.errors.append(error)
|
||||
self.set_shared_state('errors', self.errors + [error])
|
||||
|
||||
def initialize(self):
|
||||
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(f'machine:*')
|
||||
|
||||
self.discover_machine_types()
|
||||
self.discover_drivers()
|
||||
self.load_machines()
|
||||
self.load_machines(main=main)
|
||||
|
||||
def discover_machine_types(self):
|
||||
"""Discovers all machine types by inferring all classes that inherit the BaseMachineType class."""
|
||||
@ -113,26 +128,39 @@ class MachineRegistry:
|
||||
|
||||
return self.driver_instances.get(slug, None)
|
||||
|
||||
def load_machines(self):
|
||||
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)
|
||||
self.add_machine(
|
||||
machine_config, initialize=False, update_registry_hash=False
|
||||
)
|
||||
|
||||
# initialize drivers
|
||||
for driver in self.driver_instances.values():
|
||||
driver.init_driver()
|
||||
# initialize machines only in main thread
|
||||
if main:
|
||||
# initialize drivers
|
||||
for driver in self.driver_instances.values():
|
||||
driver.init_driver()
|
||||
|
||||
# initialize machines after all machine instances were created
|
||||
for machine in self.machines.values():
|
||||
if machine.active:
|
||||
machine.initialize()
|
||||
# initialize machines after all machine instances were created
|
||||
for machine in self.machines.values():
|
||||
if machine.active:
|
||||
machine.initialize()
|
||||
|
||||
logger.info('Initialized %s machines', len(self.machines.keys()))
|
||||
self._update_registry_hash()
|
||||
logger.info('Initialized %s machines', len(self.machines.keys()))
|
||||
else:
|
||||
self._hash = None # reset hash to force reload hash
|
||||
logger.info('Loaded %s machines', len(self.machines.keys()))
|
||||
|
||||
def add_machine(self, machine_config, initialize=True):
|
||||
def reload_machines(self):
|
||||
"""Reload all machines from the database."""
|
||||
self.machines = {}
|
||||
self.load_machines()
|
||||
|
||||
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)
|
||||
if machine_type is None:
|
||||
@ -145,11 +173,19 @@ class MachineRegistry:
|
||||
if initialize and machine.active:
|
||||
machine.initialize()
|
||||
|
||||
def update_machine(self, old_machine_state, machine_config):
|
||||
if update_registry_hash:
|
||||
self._update_registry_hash()
|
||||
|
||||
def update_machine(
|
||||
self, old_machine_state, machine_config, update_registry_hash=True
|
||||
):
|
||||
"""Notify the machine about an update."""
|
||||
if machine := machine_config.machine:
|
||||
machine.update(old_machine_state)
|
||||
|
||||
if update_registry_hash:
|
||||
self._update_registry_hash()
|
||||
|
||||
def restart_machine(self, machine):
|
||||
"""Restart a machine."""
|
||||
machine.restart()
|
||||
@ -157,6 +193,7 @@ class MachineRegistry:
|
||||
def remove_machine(self, machine: BaseMachineType):
|
||||
"""Remove a machine from the registry."""
|
||||
self.machines.pop(str(machine.pk), None)
|
||||
self._update_registry_hash()
|
||||
|
||||
def get_machines(self, **kwargs):
|
||||
"""Get loaded machines from registry (By default only initialized machines).
|
||||
@ -169,6 +206,8 @@ class MachineRegistry:
|
||||
active: (bool)
|
||||
base_driver: base driver (class)
|
||||
"""
|
||||
self._check_reload()
|
||||
|
||||
allowed_fields = [
|
||||
'name',
|
||||
'machine_type',
|
||||
@ -212,6 +251,7 @@ class MachineRegistry:
|
||||
|
||||
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):
|
||||
@ -222,5 +262,37 @@ class MachineRegistry:
|
||||
if driver.machine_type == machine_type
|
||||
]
|
||||
|
||||
def _calculate_registry_hash(self):
|
||||
"""Calculate a hash of the machine registry state."""
|
||||
from hashlib import md5
|
||||
|
||||
data = md5()
|
||||
|
||||
for pk, machine in self.machines.items():
|
||||
data.update(str(pk).encode())
|
||||
try:
|
||||
data.update(str(machine.machine_config.active).encode())
|
||||
except:
|
||||
# machine does not exist anymore, hash will be different
|
||||
pass
|
||||
|
||||
return str(data.hexdigest())
|
||||
|
||||
def _check_reload(self):
|
||||
"""Check if the registry needs to be reloaded, and reload it."""
|
||||
if not self._hash:
|
||||
self._hash = self._calculate_registry_hash()
|
||||
|
||||
last_hash = self.get_shared_state('hash', None)
|
||||
|
||||
if last_hash and last_hash != self._hash:
|
||||
logger.info('Machine registry has changed - reloading machines')
|
||||
self.reload_machines()
|
||||
|
||||
def _update_registry_hash(self):
|
||||
"""Save the current registry hash."""
|
||||
self._hash = self._calculate_registry_hash()
|
||||
self.set_shared_state('hash', self._hash)
|
||||
|
||||
|
||||
registry: MachineRegistry = MachineRegistry()
|
||||
|
@ -32,7 +32,7 @@ class TestMachineRegistryMixin(TestCase):
|
||||
registry.driver_instances = {}
|
||||
registry.machines = {}
|
||||
registry.base_drivers = []
|
||||
registry.errors = []
|
||||
registry.set_shared_state('errors', [])
|
||||
|
||||
return super().tearDown()
|
||||
|
||||
@ -111,7 +111,7 @@ class TestDriverMachineInterface(TestMachineRegistryMixin, TestCase):
|
||||
self.machines = [self.machine1, self.machine2, self.machine3]
|
||||
|
||||
# init registry
|
||||
registry.initialize()
|
||||
registry.initialize(main=True)
|
||||
|
||||
# mock machine implementation
|
||||
self.machine_mocks = {
|
||||
@ -230,7 +230,7 @@ class TestLabelPrinterMachineType(TestMachineRegistryMixin, InvenTreeAPITestCase
|
||||
active=True,
|
||||
)
|
||||
|
||||
registry.initialize()
|
||||
registry.initialize(main=True)
|
||||
driver_instance = cast(
|
||||
TestingLabelPrinterDriver,
|
||||
registry.get_driver_instance('testing-label-printer'),
|
||||
|
@ -93,11 +93,9 @@ class InvenTreeLabelPlugin(LabelPrintingMixin, InvenTreePlugin):
|
||||
|
||||
# execute the print job
|
||||
if driver.USE_BACKGROUND_WORKER is False:
|
||||
return driver.print_labels(machine, label, items, request, **print_kwargs)
|
||||
return driver.print_labels(machine, label, items, **print_kwargs)
|
||||
|
||||
offload_task(
|
||||
driver.print_labels, machine, label, items, request, **print_kwargs
|
||||
)
|
||||
offload_task(driver.print_labels, machine, label, items, **print_kwargs)
|
||||
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
|
@ -1,20 +1,12 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { Divider, Stack } from '@mantine/core';
|
||||
import { modals } from '@mantine/modals';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { AxiosResponse } from 'axios';
|
||||
|
||||
import { api } from '../App';
|
||||
import { ApiForm, ApiFormProps } from '../components/forms/ApiForm';
|
||||
import {
|
||||
ApiFormFieldSet,
|
||||
ApiFormFieldType
|
||||
} from '../components/forms/fields/ApiFormField';
|
||||
import { StylishText } from '../components/items/StylishText';
|
||||
import { ApiEndpoints } from '../enums/ApiEndpoints';
|
||||
import { PathParams, apiUrl } from '../states/ApiState';
|
||||
import { invalidResponse, permissionDenied } from './notifications';
|
||||
import { generateUniqueId } from './uid';
|
||||
|
||||
/**
|
||||
* Construct an API url from the provided ApiFormProps object
|
||||
@ -180,143 +172,3 @@ export function constructField({
|
||||
|
||||
return def;
|
||||
}
|
||||
|
||||
export interface OpenApiFormProps extends ApiFormProps {
|
||||
title: string;
|
||||
cancelText?: string;
|
||||
cancelColor?: string;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
/*
|
||||
* Construct and open a modal form
|
||||
* @param title :
|
||||
*/
|
||||
export function openModalApiForm(props: OpenApiFormProps) {
|
||||
// method property *must* be supplied
|
||||
if (!props.method) {
|
||||
notifications.show({
|
||||
title: t`Invalid Form`,
|
||||
message: t`method parameter not supplied`,
|
||||
color: 'red'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate a random modal ID for controller
|
||||
let modalId: string =
|
||||
`modal-${props.title}-${props.url}-${props.method}` + generateUniqueId();
|
||||
|
||||
props.actions = [
|
||||
...(props.actions || []),
|
||||
{
|
||||
text: props.cancelText ?? t`Cancel`,
|
||||
color: props.cancelColor ?? 'blue',
|
||||
onClick: () => {
|
||||
modals.close(modalId);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const oldFormSuccess = props.onFormSuccess;
|
||||
props.onFormSuccess = (data) => {
|
||||
oldFormSuccess?.(data);
|
||||
modals.close(modalId);
|
||||
};
|
||||
|
||||
let url = constructFormUrl(props.url, props.pk, props.pathParams);
|
||||
|
||||
// Make OPTIONS request first
|
||||
api
|
||||
.options(url)
|
||||
.then((response) => {
|
||||
// Extract available fields from the OPTIONS response (and handle any errors)
|
||||
|
||||
let fields: Record<string, ApiFormFieldType> | null = {};
|
||||
|
||||
if (!props.ignorePermissionCheck) {
|
||||
fields = extractAvailableFields(response, props.method);
|
||||
|
||||
if (fields == null) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const _props = { ...props };
|
||||
|
||||
if (_props.fields) {
|
||||
for (const [k, v] of Object.entries(_props.fields)) {
|
||||
_props.fields[k] = constructField({
|
||||
field: v,
|
||||
definition: fields?.[k]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
modals.open({
|
||||
title: <StylishText size="xl">{props.title}</StylishText>,
|
||||
modalId: modalId,
|
||||
size: 'xl',
|
||||
onClose: () => {
|
||||
props.onClose ? props.onClose() : null;
|
||||
},
|
||||
children: (
|
||||
<Stack gap={'xs'}>
|
||||
<Divider />
|
||||
<ApiForm id={modalId} props={props} optionsLoading={false} />
|
||||
</Stack>
|
||||
)
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
if (error.response) {
|
||||
invalidResponse(error.response.status);
|
||||
} else {
|
||||
notifications.show({
|
||||
title: t`Form Error`,
|
||||
message: error.message,
|
||||
color: 'red'
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a modal form to create a new model instance
|
||||
*/
|
||||
export function openCreateApiForm(props: OpenApiFormProps) {
|
||||
let createProps: OpenApiFormProps = {
|
||||
...props,
|
||||
method: 'POST'
|
||||
};
|
||||
|
||||
openModalApiForm(createProps);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a modal form to edit a model instance
|
||||
*/
|
||||
export function openEditApiForm(props: OpenApiFormProps) {
|
||||
let editProps: OpenApiFormProps = {
|
||||
...props,
|
||||
fetchInitialData: props.fetchInitialData ?? true,
|
||||
method: 'PATCH'
|
||||
};
|
||||
|
||||
openModalApiForm(editProps);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a modal form to delete a model instancel
|
||||
*/
|
||||
export function openDeleteApiForm(props: OpenApiFormProps) {
|
||||
let deleteProps: OpenApiFormProps = {
|
||||
...props,
|
||||
method: 'DELETE',
|
||||
submitText: t`Delete`,
|
||||
submitColor: 'red',
|
||||
fields: {}
|
||||
};
|
||||
|
||||
openModalApiForm(deleteProps);
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
import '@mantine/carousel/styles.css';
|
||||
import '@mantine/charts/styles.css';
|
||||
import '@mantine/core/styles.css';
|
||||
import '@mantine/dates/styles.css';
|
||||
import '@mantine/notifications/styles.css';
|
||||
import '@mantine/spotlight/styles.css';
|
||||
import * as Sentry from '@sentry/react';
|
||||
|
@ -41,8 +41,11 @@ import {
|
||||
} from '../../components/render/StatusRenderer';
|
||||
import { MachineSettingList } from '../../components/settings/SettingList';
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
import { openDeleteApiForm, openEditApiForm } from '../../functions/forms';
|
||||
import { useCreateApiFormModal } from '../../hooks/UseForm';
|
||||
import {
|
||||
useCreateApiFormModal,
|
||||
useDeleteApiFormModal,
|
||||
useEditApiFormModal
|
||||
} from '../../hooks/UseForm';
|
||||
import { useTable } from '../../hooks/UseTable';
|
||||
import { apiUrl } from '../../states/ApiState';
|
||||
import { TableColumn } from '../Column';
|
||||
@ -205,8 +208,39 @@ function MachineDrawer({
|
||||
[refreshAll]
|
||||
);
|
||||
|
||||
const machineEditModal = useEditApiFormModal({
|
||||
title: t`Edit machine`,
|
||||
url: ApiEndpoints.machine_list,
|
||||
pk: machinePk,
|
||||
fields: useMemo(
|
||||
() => ({
|
||||
name: {},
|
||||
active: {}
|
||||
}),
|
||||
[]
|
||||
),
|
||||
onClose: () => refreshAll()
|
||||
});
|
||||
|
||||
const machineDeleteModal = useDeleteApiFormModal({
|
||||
title: t`Delete machine`,
|
||||
successMessage: t`Machine successfully deleted.`,
|
||||
url: ApiEndpoints.machine_list,
|
||||
pk: machinePk,
|
||||
preFormContent: (
|
||||
<Text>{t`Are you sure you want to remove the machine "${machine?.name}"?`}</Text>
|
||||
),
|
||||
onFormSuccess: () => {
|
||||
refreshTable();
|
||||
navigate(-1);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
{machineEditModal.modal}
|
||||
{machineDeleteModal.modal}
|
||||
|
||||
<Group justify="space-between">
|
||||
<Box></Box>
|
||||
|
||||
@ -227,36 +261,11 @@ function MachineDrawer({
|
||||
actions={[
|
||||
EditItemAction({
|
||||
tooltip: t`Edit machine`,
|
||||
onClick: () => {
|
||||
openEditApiForm({
|
||||
title: t`Edit machine`,
|
||||
url: ApiEndpoints.machine_list,
|
||||
pk: machinePk,
|
||||
fields: {
|
||||
name: {},
|
||||
active: {}
|
||||
},
|
||||
onClose: () => refreshAll()
|
||||
});
|
||||
}
|
||||
onClick: machineEditModal.open
|
||||
}),
|
||||
DeleteItemAction({
|
||||
tooltip: t`Delete machine`,
|
||||
onClick: () => {
|
||||
openDeleteApiForm({
|
||||
title: t`Delete machine`,
|
||||
successMessage: t`Machine successfully deleted.`,
|
||||
url: ApiEndpoints.machine_list,
|
||||
pk: machinePk,
|
||||
preFormContent: (
|
||||
<Text>{t`Are you sure you want to remove the machine "${machine?.name}"?`}</Text>
|
||||
),
|
||||
onFormSuccess: () => {
|
||||
refreshTable();
|
||||
navigate(-1);
|
||||
}
|
||||
});
|
||||
}
|
||||
onClick: machineDeleteModal.open
|
||||
}),
|
||||
{
|
||||
icon: <IconRefresh />,
|
||||
|
Loading…
Reference in New Issue
Block a user