From 9708a218e0dd9ddc679819175f1419333cd7a00b Mon Sep 17 00:00:00 2001 From: wolflu05 <76838159+wolflu05@users.noreply.github.com> Date: Mon, 15 May 2023 10:18:00 +0000 Subject: [PATCH 01/86] Added initial draft for machines --- InvenTree/InvenTree/settings.py | 1 + InvenTree/common/models.py | 14 ++ InvenTree/machine/__init__.py | 0 InvenTree/machine/admin.py | 44 ++++++ InvenTree/machine/apps.py | 23 +++ .../base_drivers/BaseLabelPrintingDriver.py | 23 +++ InvenTree/machine/base_drivers/__init__.py | 6 + InvenTree/machine/driver.py | 52 +++++++ InvenTree/machine/migrations/0001_initial.py | 37 +++++ InvenTree/machine/migrations/__init__.py | 0 InvenTree/machine/models.py | 146 ++++++++++++++++++ InvenTree/machine/registry.py | 84 ++++++++++ InvenTree/machine/tests.py | 3 + InvenTree/machine/views.py | 3 + 14 files changed, 436 insertions(+) create mode 100755 InvenTree/machine/__init__.py create mode 100755 InvenTree/machine/admin.py create mode 100755 InvenTree/machine/apps.py create mode 100644 InvenTree/machine/base_drivers/BaseLabelPrintingDriver.py create mode 100644 InvenTree/machine/base_drivers/__init__.py create mode 100644 InvenTree/machine/driver.py create mode 100644 InvenTree/machine/migrations/0001_initial.py create mode 100755 InvenTree/machine/migrations/__init__.py create mode 100755 InvenTree/machine/models.py create mode 100644 InvenTree/machine/registry.py create mode 100755 InvenTree/machine/tests.py create mode 100755 InvenTree/machine/views.py diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py index 0632f782453..88545246d92 100644 --- a/InvenTree/InvenTree/settings.py +++ b/InvenTree/InvenTree/settings.py @@ -196,6 +196,7 @@ 'stock.apps.StockConfig', 'users.apps.UsersConfig', 'plugin.apps.PluginAppConfig', + 'machine.apps.MachineConfig', 'InvenTree.apps.InvenTreeConfig', # InvenTree app runs last # Core django modules diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py index 16545e5914e..765875bb77c 100644 --- a/InvenTree/common/models.py +++ b/InvenTree/common/models.py @@ -381,6 +381,12 @@ def get_setting_object(cls, key, **kwargs): filters['plugin'] = plugin kwargs['plugin'] = plugin + # Filter by machine + machine = kwargs.get('machine', None) + + if machine is not None: + filters['machine'] = machine + # Filter by method method = kwargs.get('method', None) @@ -497,6 +503,7 @@ def set_setting(cls, key, value, change_user, create=True, **kwargs): user = kwargs.get('user', None) plugin = kwargs.get('plugin', None) + machine = kwargs.get('machine', None) if user is not None: filters['user'] = user @@ -509,6 +516,9 @@ def set_setting(cls, key, value, change_user, create=True, **kwargs): else: filters['plugin'] = plugin + if machine is not None: + filters['machine'] = machine + try: setting = cls.objects.get(**filters) except cls.DoesNotExist: @@ -629,6 +639,7 @@ def validate_unique(self, exclude=None, **kwargs): user = getattr(self, 'user', None) plugin = getattr(self, 'plugin', None) + machine = getattr(self, 'machine', None) if user is not None: filters['user'] = user @@ -636,6 +647,9 @@ def validate_unique(self, exclude=None, **kwargs): if plugin is not None: filters['plugin'] = plugin + if machine is not None: + filters['machine'] = machine + try: # Check if a duplicate setting already exists setting = self.__class__.objects.filter(**filters).exclude(id=self.id) diff --git a/InvenTree/machine/__init__.py b/InvenTree/machine/__init__.py new file mode 100755 index 00000000000..e69de29bb2d diff --git a/InvenTree/machine/admin.py b/InvenTree/machine/admin.py new file mode 100755 index 00000000000..136dc63ca9e --- /dev/null +++ b/InvenTree/machine/admin.py @@ -0,0 +1,44 @@ +from django import forms +from django.contrib import admin +from django.utils.translation import gettext_lazy as _ + +from machine import models +from machine.registry import registry + + +class MachineAdminForm(forms.ModelForm): + def get_machine_type_choices(): + return [(machine_type.SLUG, machine_type.NAME) for machine_type in registry.machine_types.values()] + + def get_driver_choices(): + return [(driver.SLUG, driver.NAME) for driver in registry.drivers.values()] + + # TODO: add conditional choices like shown here + # Ref: https://www.reddit.com/r/django/comments/18cj55/conditional_choices_for_model_field_based_on/ + # Ref: https://gist.github.com/blackrobot/4956070 + driver_key = forms.ChoiceField(label=_("Driver"), choices=get_driver_choices) + machine_type_key = forms.ChoiceField(label=_("Machine Type"), choices=get_machine_type_choices) + + +class MachineSettingInline(admin.TabularInline): + """Inline admin class for MachineSetting.""" + + model = models.MachineSetting + + read_only_fields = [ + 'key', + ] + + def has_add_permission(self, request, obj): + """The machine settings should not be meddled with manually.""" + return False + + +@admin.register(models.Machine) +class MachineAdmin(admin.ModelAdmin): + """Custom admin with restricted id fields.""" + + form = MachineAdminForm + list_filter = ["active"] + list_display = ["name", "machine_type_key", "driver_key", "active", "is_driver_available", "no_errors"] + inlines = [MachineSettingInline] diff --git a/InvenTree/machine/apps.py b/InvenTree/machine/apps.py new file mode 100755 index 00000000000..839a5c08ccb --- /dev/null +++ b/InvenTree/machine/apps.py @@ -0,0 +1,23 @@ +import logging + +from django.apps import AppConfig + +from InvenTree.ready import canAppAccessDatabase +from plugin import registry as plg_registry + +logger = logging.getLogger('inventree') + + +class MachineConfig(AppConfig): + name = 'machine' + + def ready(self) -> None: + """Initialization method for the Machine app.""" + if not canAppAccessDatabase(allow_test=True) or plg_registry.is_loading: + logger.info("Skipping machine loading sequence") + return + + from machine.registry import registry + + logger.info("Loading InvenTree machines") + registry.initialize() diff --git a/InvenTree/machine/base_drivers/BaseLabelPrintingDriver.py b/InvenTree/machine/base_drivers/BaseLabelPrintingDriver.py new file mode 100644 index 00000000000..cee24255876 --- /dev/null +++ b/InvenTree/machine/base_drivers/BaseLabelPrintingDriver.py @@ -0,0 +1,23 @@ +from django.utils.translation import gettext_lazy as _ + +from machine.driver import BaseDriver, BaseMachineType + + +class BaseLabelPrintingDriver(BaseDriver): + """Base label printing driver.""" + + def print_label(): + """This function must be overriden.""" + raise NotImplementedError("The `print_label` function must be overriden!") + + def print_labels(): + """This function must be overriden.""" + raise NotImplementedError("The `print_labels` function must be overriden!") + + +class LabelPrintingMachineType(BaseMachineType): + SLUG = "label_printer" + NAME = _("Label Printer") + DESCRIPTION = _("Label printer used to print labels") + + base_driver = BaseLabelPrintingDriver diff --git a/InvenTree/machine/base_drivers/__init__.py b/InvenTree/machine/base_drivers/__init__.py new file mode 100644 index 00000000000..1226e21fb37 --- /dev/null +++ b/InvenTree/machine/base_drivers/__init__.py @@ -0,0 +1,6 @@ +from machine.base_drivers.BaseLabelPrintingDriver import \ + BaseLabelPrintingDriver + +__all__ = [ + "BaseLabelPrintingDriver", +] diff --git a/InvenTree/machine/driver.py b/InvenTree/machine/driver.py new file mode 100644 index 00000000000..9dc7b0ca1cc --- /dev/null +++ b/InvenTree/machine/driver.py @@ -0,0 +1,52 @@ +from typing import Dict + + +class BaseDriver: + """Base class for machine drivers + + Attributes: + SLUG: Slug string for identifying a machine + NAME: User friendly name for displaying + DESCRIPTION: Description of what this driver does (default: "") + """ + + SLUG: str + NAME: str + DESCRIPTION: str = "" + + # Import only for typechecking + from typing import TYPE_CHECKING + if TYPE_CHECKING: + from machine.models import Machine + else: # pragma: no cover + class Machine: + pass + + # TODO: add better typing + MACHINE_SETTINGS: Dict[str, dict] + + def init_machine(self, machine: Machine): + """This method get called for each active machine using that driver while initialization + + Arguments: + machine: Machine instance + """ + pass + + +class BaseMachineType: + """Base class for machine types + + Attributes: + SLUG: Slug string for identifying a machine type + NAME: User friendly name for displaying + DESCRIPTION: Description of what this machine type can do (default: "") + + base_driver: Reference to the base driver for this machine type + """ + + SLUG: str + NAME: str + DESCRIPTION: str = "" + + base_driver: BaseDriver diff --git a/InvenTree/machine/migrations/0001_initial.py b/InvenTree/machine/migrations/0001_initial.py new file mode 100644 index 00000000000..b2ac5c70de4 --- /dev/null +++ b/InvenTree/machine/migrations/0001_initial.py @@ -0,0 +1,37 @@ +# Generated by Django 3.2.19 on 2023-05-14 18:39 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Machine', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(help_text='Name of machine', max_length=255, unique=True, verbose_name='Name')), + ('machine_type_key', models.CharField(help_text='Type of machine', max_length=255, verbose_name='Machine Type')), + ('driver_key', models.CharField(help_text='Driver used for the machine', max_length=255, verbose_name='Driver')), + ('active', models.BooleanField(default=True, help_text='Machines can be disabled', verbose_name='Active')), + ], + ), + migrations.CreateModel( + name='MachineSetting', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('key', models.CharField(help_text='Settings key (must be unique - case insensitive)', max_length=50)), + ('value', models.CharField(blank=True, help_text='Settings value', max_length=200)), + ('machine', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='settings', to='machine.machine', verbose_name='Machine')), + ], + options={ + 'unique_together': {('machine', 'key')}, + }, + ), + ] diff --git a/InvenTree/machine/migrations/__init__.py b/InvenTree/machine/migrations/__init__.py new file mode 100755 index 00000000000..e69de29bb2d diff --git a/InvenTree/machine/models.py b/InvenTree/machine/models.py new file mode 100755 index 00000000000..78ebc152e7f --- /dev/null +++ b/InvenTree/machine/models.py @@ -0,0 +1,146 @@ +from typing import Any + +from django.contrib import admin +from django.db import models +from django.utils.translation import gettext_lazy as _ + +import common.models +from machine.registry import registry + + +class Machine(models.Model): + """A Machine objects represents a physical machine.""" + + name = models.CharField( + unique=True, + max_length=255, + verbose_name=_("Name"), + help_text=_("Name of machine") + ) + + machine_type_key = models.CharField( + max_length=255, + verbose_name=_("Machine Type"), + help_text=_("Type of machine"), + ) + + driver_key = models.CharField( + max_length=255, + verbose_name=_("Driver"), + help_text=_("Driver used for the machine") + ) + + active = models.BooleanField( + default=True, + verbose_name=_("Active"), + help_text=_("Machines can be disabled") + ) + + def __str__(self) -> str: + """String representation of a machine.""" + return f"{self.name}" + + def __init__(self, *args: Any, **kwargs: Any) -> None: + """Override to set origignal state of the machine instance.""" + super().__init__(*args, **kwargs) + + self.errors = [] + + self.driver = registry.get_driver_instance(self.driver_key) + self.machine_type = registry.machine_types.get(self.machine_type_key, None) + + if not self.driver: + self.errors.append(f"Driver '{self.driver_key}' not found") + if not self.machine_type: + self.errors.append(f"Machine type '{self.machine_type_key}' not found") + if self.machine_type and not isinstance(self.driver, self.machine_type.base_driver): + self.errors.append(f"'{self.driver.NAME}' is incompatibe with machine type '{self.machine_type.NAME}'") + + if len(self.errors) > 0: + return + + # TODO: add other init stuff here + + @admin.display(boolean=True, description=_("Driver available")) + def is_driver_available(self) -> bool: + """Status if driver for machine is available""" + return self.driver is not None + + @admin.display(boolean=True, description=_("Machine has no errors")) + def no_errors(self) -> bool: + """Status if machine has errors""" + return len(self.errors) == 0 + + def initialize(self): + """Machine initialitation function, gets called after all machines are loaded""" + if self.driver is None: + return + + self.driver.init_machine(self) + + def get_setting(self, key, cache=False): + """Return the 'value' of the setting associated with this machine. + + Arguments: + key: The 'name' of the setting value to be retrieved + cache: Whether to use RAM cached value (default = False) + """ + from machine.models import MachineSetting + + return MachineSetting.get_setting(key, machine=self, cache=cache) + + def set_setting(self, key, value, user=None): + """Set plugin setting value by key. + + Arguments: + key: The 'name' of the setting to set + value: The 'value' of the setting + user: TODO: what is this doing? + """ + from machine.models import MachineSetting + + MachineSetting.set_setting(key, value, user, machine=self) + + +class MachineSetting(common.models.BaseInvenTreeSetting): + """This models represents settings for individial machines.""" + + typ = "machine" + + class Meta: + """Meta for MachineSetting.""" + unique_together = [ + ("machine", "key") + ] + + machine = models.ForeignKey( + Machine, + related_name="settings", + verbose_name=_("Machine"), + on_delete=models.CASCADE + ) + + @classmethod + def get_setting_definition(cls, key, **kwargs): + """In the BaseInvenTreeSetting class, we have a class attribute named 'SETTINGS', which + is a dict object that fully defines all the setting parameters. + + Here, unlike the BaseInvenTreeSetting, we do not know the definitions of all settings + 'ahead of time' (as they are defined externally in the machine). + + Settings can be provided by the caller, as kwargs['settings']. + + If not provided, we'll look at the machine registry to see what settings this machine requires + """ + if 'settings' not in kwargs: + machine: Machine = kwargs.pop('machine', None) + if machine: + kwargs['settings'] = getattr(machine.driver, "MACHINE_SETTINGS", {}) + + return super().get_setting_definition(key, **kwargs) + + def get_kwargs(self): + """Explicit kwargs required to uniquely identify a particular setting object, in addition to the 'key' parameter.""" + return { + 'machine': self.machine, + } diff --git a/InvenTree/machine/registry.py b/InvenTree/machine/registry.py new file mode 100644 index 00000000000..8e56bb30405 --- /dev/null +++ b/InvenTree/machine/registry.py @@ -0,0 +1,84 @@ +import logging +from typing import Dict, List + +import InvenTree.helpers +from machine.driver import BaseDriver, BaseMachineType + +logger = logging.getLogger('inventree') + + +class MachinesRegistry: + def __init__(self) -> None: + """Initialize machine registry + + Set up all needed references for internal and external states. + """ + + # Import only for typechecking + from typing import TYPE_CHECKING + if TYPE_CHECKING: + from machine.models import Machine + + self.machine_types: Dict[str, BaseMachineType] = {} + self.drivers: Dict[str, BaseDriver] = {} + self.driver_instances: Dict[str, BaseDriver] = {} + self.machines: Dict[str, Machine] = {} + + self.base_drivers: List[BaseDriver] = [] + + def initialize(self): + print("INITIALIZE") # TODO: remove debug statement + self.discover_machine_types() + self.discover_drivers() + self.load_machines() + + def discover_machine_types(self): + logger.debug("Collecting machine types") + + machine_types: List[BaseMachineType] = InvenTree.helpers.inheritors(BaseMachineType) + for machine_type in machine_types: + self.machine_types[machine_type.SLUG] = machine_type + self.base_drivers.append(machine_type.base_driver) + + logger.debug(f"Found {len(self.machine_types.keys())} machine types") + + def discover_drivers(self): + logger.debug("Collecting machine drivers") + + drivers: List[BaseDriver] = InvenTree.helpers.inheritors(BaseDriver) + for driver in drivers: + # skip discovered drivers that define a base driver for a machine type + if driver in self.base_drivers: + continue + + self.drivers[driver.SLUG] = driver + + logger.debug(f"Found {len(self.drivers.keys())} machine drivers") + + def get_driver_instance(self, slug: str): + if slug not in self.driver_instances: + driver = self.drivers.get(slug, None) + if driver is None: + return None + + self.driver_instances[slug] = driver() + + return self.driver_instances.get(slug, None) + + def load_machines(self): + # Imports need to be in this level to prevent early db model imports + from machine.models import Machine + + for machine in Machine.objects.all(): + self.machines[machine.name] = machine + + # initialize machines after all machine instances were created + for machine in self.machines.values(): + machine.initialize() + + def get_machines(self, machine_type): + # TODO: implement function + pass + + +registry: MachinesRegistry = MachinesRegistry() diff --git a/InvenTree/machine/tests.py b/InvenTree/machine/tests.py new file mode 100755 index 00000000000..a79ca8be565 --- /dev/null +++ b/InvenTree/machine/tests.py @@ -0,0 +1,3 @@ +# from django.test import TestCase + +# Create your tests here. diff --git a/InvenTree/machine/views.py b/InvenTree/machine/views.py new file mode 100755 index 00000000000..fd0e0449559 --- /dev/null +++ b/InvenTree/machine/views.py @@ -0,0 +1,3 @@ +# from django.shortcuts import render + +# Create your views here. From e713c5ae6504bfd548b8d7586a348d21fa8ed9f1 Mon Sep 17 00:00:00 2001 From: wolflu05 <76838159+wolflu05@users.noreply.github.com> Date: Mon, 15 May 2023 17:28:46 +0000 Subject: [PATCH 02/86] refactor: isPluginRegistryLoaded check into own ready function --- InvenTree/InvenTree/ready.py | 18 ++++++++++++++++++ InvenTree/machine/apps.py | 5 ++--- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/InvenTree/InvenTree/ready.py b/InvenTree/InvenTree/ready.py index e6a4ec9ae26..18dba1f4d56 100644 --- a/InvenTree/InvenTree/ready.py +++ b/InvenTree/InvenTree/ready.py @@ -60,3 +60,21 @@ def canAppAccessDatabase(allow_test: bool = False, allow_plugins: bool = False, return False return True + + +def isPluginRegistryLoaded(): + """The plugin registry reloads all apps onetime after starting so that the discovered AppConfigs are added to Django. + + This triggeres the ready function of AppConfig to execute twice. Add this check to prevent from running two times. + + Returns: 'False' if the apps have not been reloaded already to prevent running the ready function twice + """ + from django.conf import settings + + # If plugins are not enabled, there wont be a second load + if not settings.PLUGINS_ENABLED: + return True + + from plugin import registry + + return not registry.is_loading diff --git a/InvenTree/machine/apps.py b/InvenTree/machine/apps.py index 839a5c08ccb..ca57daf9c01 100755 --- a/InvenTree/machine/apps.py +++ b/InvenTree/machine/apps.py @@ -2,8 +2,7 @@ from django.apps import AppConfig -from InvenTree.ready import canAppAccessDatabase -from plugin import registry as plg_registry +from InvenTree.ready import canAppAccessDatabase, isPluginRegistryLoaded logger = logging.getLogger('inventree') @@ -13,7 +12,7 @@ class MachineConfig(AppConfig): def ready(self) -> None: """Initialization method for the Machine app.""" - if not canAppAccessDatabase(allow_test=True) or plg_registry.is_loading: + if not canAppAccessDatabase(allow_test=True) or not isPluginRegistryLoaded(): logger.info("Skipping machine loading sequence") return From 7861b7341d7649616679d835c61048272e54aa91 Mon Sep 17 00:00:00 2001 From: wolflu05 <76838159+wolflu05@users.noreply.github.com> Date: Mon, 15 May 2023 18:11:50 +0000 Subject: [PATCH 03/86] Added suggestions from codereview --- InvenTree/machine/__init__.py | 5 ++++ InvenTree/machine/apps.py | 2 +- .../base_drivers/BaseLabelPrintingDriver.py | 2 +- InvenTree/machine/registry.py | 24 +++++++++++++------ 4 files changed, 24 insertions(+), 9 deletions(-) diff --git a/InvenTree/machine/__init__.py b/InvenTree/machine/__init__.py index e69de29bb2d..3677f302bf6 100755 --- a/InvenTree/machine/__init__.py +++ b/InvenTree/machine/__init__.py @@ -0,0 +1,5 @@ +from machine.registry import registry + +__all__ = [ + "registry" +] diff --git a/InvenTree/machine/apps.py b/InvenTree/machine/apps.py index ca57daf9c01..545260e1359 100755 --- a/InvenTree/machine/apps.py +++ b/InvenTree/machine/apps.py @@ -13,7 +13,7 @@ class MachineConfig(AppConfig): def ready(self) -> None: """Initialization method for the Machine app.""" if not canAppAccessDatabase(allow_test=True) or not isPluginRegistryLoaded(): - logger.info("Skipping machine loading sequence") + logger.debug("Machine app: Skipping machine loading sequence") return from machine.registry import registry diff --git a/InvenTree/machine/base_drivers/BaseLabelPrintingDriver.py b/InvenTree/machine/base_drivers/BaseLabelPrintingDriver.py index cee24255876..8040f50e677 100644 --- a/InvenTree/machine/base_drivers/BaseLabelPrintingDriver.py +++ b/InvenTree/machine/base_drivers/BaseLabelPrintingDriver.py @@ -18,6 +18,6 @@ def print_labels(): class LabelPrintingMachineType(BaseMachineType): SLUG = "label_printer" NAME = _("Label Printer") - DESCRIPTION = _("Label printer used to print labels") + DESCRIPTION = _("Device used to print labels") base_driver = BaseLabelPrintingDriver diff --git a/InvenTree/machine/registry.py b/InvenTree/machine/registry.py index 8e56bb30405..5aced7638d2 100644 --- a/InvenTree/machine/registry.py +++ b/InvenTree/machine/registry.py @@ -35,23 +35,33 @@ def initialize(self): def discover_machine_types(self): logger.debug("Collecting machine types") - machine_types: List[BaseMachineType] = InvenTree.helpers.inheritors(BaseMachineType) - for machine_type in machine_types: - self.machine_types[machine_type.SLUG] = machine_type - self.base_drivers.append(machine_type.base_driver) + machine_types: Dict[str, BaseMachineType] = {} + base_drivers: List[BaseDriver] = [] + + discovered_machine_types: List[BaseMachineType] = InvenTree.helpers.inheritors(BaseMachineType) + for machine_type in discovered_machine_types: + machine_types[machine_type.SLUG] = machine_type + base_drivers.append(machine_type.base_driver) + + self.machine_types = machine_types + self.base_drivers = base_drivers logger.debug(f"Found {len(self.machine_types.keys())} machine types") def discover_drivers(self): logger.debug("Collecting machine drivers") - drivers: List[BaseDriver] = InvenTree.helpers.inheritors(BaseDriver) - for driver in drivers: + drivers: Dict[str, BaseDriver] = {} + + discovered_drivers: List[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 - self.drivers[driver.SLUG] = driver + drivers[driver.SLUG] = driver + + self.drivers = drivers logger.debug(f"Found {len(self.drivers.keys())} machine drivers") From aee3d5737d43eb5f4dd0e42a77c9d0075c7697ff Mon Sep 17 00:00:00 2001 From: wolflu05 <76838159+wolflu05@users.noreply.github.com> Date: Thu, 18 May 2023 11:40:56 +0000 Subject: [PATCH 04/86] Refactor: base_drivers -> machine_types --- InvenTree/machine/{driver.py => machine_type.py} | 0 .../LabelPrintingMachineType.py} | 2 +- InvenTree/machine/{base_drivers => machine_types}/__init__.py | 2 +- InvenTree/machine/registry.py | 2 +- 4 files changed, 3 insertions(+), 3 deletions(-) rename InvenTree/machine/{driver.py => machine_type.py} (100%) rename InvenTree/machine/{base_drivers/BaseLabelPrintingDriver.py => machine_types/LabelPrintingMachineType.py} (91%) rename InvenTree/machine/{base_drivers => machine_types}/__init__.py (54%) diff --git a/InvenTree/machine/driver.py b/InvenTree/machine/machine_type.py similarity index 100% rename from InvenTree/machine/driver.py rename to InvenTree/machine/machine_type.py diff --git a/InvenTree/machine/base_drivers/BaseLabelPrintingDriver.py b/InvenTree/machine/machine_types/LabelPrintingMachineType.py similarity index 91% rename from InvenTree/machine/base_drivers/BaseLabelPrintingDriver.py rename to InvenTree/machine/machine_types/LabelPrintingMachineType.py index 8040f50e677..c114b440c98 100644 --- a/InvenTree/machine/base_drivers/BaseLabelPrintingDriver.py +++ b/InvenTree/machine/machine_types/LabelPrintingMachineType.py @@ -1,6 +1,6 @@ from django.utils.translation import gettext_lazy as _ -from machine.driver import BaseDriver, BaseMachineType +from machine.machine_type import BaseDriver, BaseMachineType class BaseLabelPrintingDriver(BaseDriver): diff --git a/InvenTree/machine/base_drivers/__init__.py b/InvenTree/machine/machine_types/__init__.py similarity index 54% rename from InvenTree/machine/base_drivers/__init__.py rename to InvenTree/machine/machine_types/__init__.py index 1226e21fb37..8fc4f82fba0 100644 --- a/InvenTree/machine/base_drivers/__init__.py +++ b/InvenTree/machine/machine_types/__init__.py @@ -1,4 +1,4 @@ -from machine.base_drivers.BaseLabelPrintingDriver import \ +from machine.machine_types.LabelPrintingMachineType import \ BaseLabelPrintingDriver __all__ = [ diff --git a/InvenTree/machine/registry.py b/InvenTree/machine/registry.py index 5aced7638d2..69848896b4b 100644 --- a/InvenTree/machine/registry.py +++ b/InvenTree/machine/registry.py @@ -2,7 +2,7 @@ from typing import Dict, List import InvenTree.helpers -from machine.driver import BaseDriver, BaseMachineType +from machine.machine_type import BaseDriver, BaseMachineType logger = logging.getLogger('inventree') From 9ed334d7acd987243fa7cf248b3aae587d5f4c00 Mon Sep 17 00:00:00 2001 From: wolflu05 <76838159+wolflu05@users.noreply.github.com> Date: Fri, 19 May 2023 05:26:30 +0000 Subject: [PATCH 05/86] Use new BaseInvenTreeSetting unique interface --- InvenTree/machine/models.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/InvenTree/machine/models.py b/InvenTree/machine/models.py index 78ebc152e7f..eab95132d2e 100755 --- a/InvenTree/machine/models.py +++ b/InvenTree/machine/models.py @@ -106,6 +106,7 @@ class MachineSetting(common.models.BaseInvenTreeSetting): """This models represents settings for individial machines.""" typ = "machine" + extra_unique_fields = ["machine"] class Meta: """Meta for MachineSetting.""" @@ -138,9 +139,3 @@ def get_setting_definition(cls, key, **kwargs): kwargs['settings'] = getattr(machine.driver, "MACHINE_SETTINGS", {}) return super().get_setting_definition(key, **kwargs) - - def get_kwargs(self): - """Explicit kwargs required to uniquely identify a particular setting object, in addition to the 'key' parameter.""" - return { - 'machine': self.machine, - } From 621485b05e91cd4eba242efbd67f8ff67e597bb3 Mon Sep 17 00:00:00 2001 From: wolflu05 <76838159+wolflu05@users.noreply.github.com> Date: Fri, 19 May 2023 05:40:18 +0000 Subject: [PATCH 06/86] Fix Django not ready error --- InvenTree/machine/registry.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/InvenTree/machine/registry.py b/InvenTree/machine/registry.py index 69848896b4b..63e5aee55c5 100644 --- a/InvenTree/machine/registry.py +++ b/InvenTree/machine/registry.py @@ -1,7 +1,6 @@ import logging from typing import Dict, List -import InvenTree.helpers from machine.machine_type import BaseDriver, BaseMachineType logger = logging.getLogger('inventree') @@ -33,6 +32,8 @@ def initialize(self): self.load_machines() def discover_machine_types(self): + import InvenTree.helpers + logger.debug("Collecting machine types") machine_types: Dict[str, BaseMachineType] = {} @@ -49,6 +50,8 @@ def discover_machine_types(self): logger.debug(f"Found {len(self.machine_types.keys())} machine types") def discover_drivers(self): + import InvenTree.helpers + logger.debug("Collecting machine drivers") drivers: Dict[str, BaseDriver] = {} From 81389df9a9660988eab385bfa647a06e7c3b2a2c Mon Sep 17 00:00:00 2001 From: wolflu05 <76838159+wolflu05@users.noreply.github.com> Date: Fri, 19 May 2023 06:36:39 +0000 Subject: [PATCH 07/86] Added get_machines function to driver - get_machines function on driver - get_machine function on driver - initialized attribute on machine --- InvenTree/machine/machine_type.py | 6 +++++ InvenTree/machine/models.py | 3 +++ InvenTree/machine/registry.py | 38 +++++++++++++++++++++++++++---- 3 files changed, 43 insertions(+), 4 deletions(-) diff --git a/InvenTree/machine/machine_type.py b/InvenTree/machine/machine_type.py index 9dc7b0ca1cc..40a78510d8a 100644 --- a/InvenTree/machine/machine_type.py +++ b/InvenTree/machine/machine_type.py @@ -33,6 +33,12 @@ def init_machine(self, machine: Machine): """ pass + def get_machines(self): + """Return all machines using this driver.""" + from machine import registry + + return registry.get_machines(driver=self) + class BaseMachineType: """Base class for machine types diff --git a/InvenTree/machine/models.py b/InvenTree/machine/models.py index eab95132d2e..15498290752 100755 --- a/InvenTree/machine/models.py +++ b/InvenTree/machine/models.py @@ -45,6 +45,7 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) self.errors = [] + self.initialized = False self.driver = registry.get_driver_instance(self.driver_key) self.machine_type = registry.machine_types.get(self.machine_type_key, None) @@ -78,6 +79,8 @@ def initialize(self): self.driver.init_machine(self) + self.initialized = True + def get_setting(self, key, cache=False): """Return the 'value' of the setting associated with this machine. diff --git a/InvenTree/machine/registry.py b/InvenTree/machine/registry.py index 63e5aee55c5..9b9a8eb50e3 100644 --- a/InvenTree/machine/registry.py +++ b/InvenTree/machine/registry.py @@ -83,15 +83,45 @@ def load_machines(self): from machine.models import Machine for machine in Machine.objects.all(): - self.machines[machine.name] = machine + self.machines[machine.pk] = machine # initialize machines after all machine instances were created for machine in self.machines.values(): machine.initialize() - def get_machines(self, machine_type): - # TODO: implement function - pass + def get_machines(self, **kwargs): + """Get loaded machines from registry. + + Kwargs: + name: Machine name + machine_type: Machine type defition (class) + driver: Machine driver (class) + active: (bool) + base_driver: base driver (class | List[class]) + """ + allowed_fields = ["name", "machine_type", "driver", "active", "base_driver"] + + def filter_machine(machine): + for key, value in kwargs.items(): + if key not in allowed_fields: + continue + + # check if current driver is subclass from base_driver + if key == "base_driver": + if machine.driver and not issubclass(machine.driver.__class__, value): + return False + + # check attributes of machine + elif value != getattr(machine, key, None): + return False + + return True + + return list(filter(filter_machine, self.machines.values())) + + def get_machine(self, pk): + """Get machine from registry by pk.""" + return self.machines.get(pk, None) registry: MachinesRegistry = MachinesRegistry() From 8aeb81ec2ea4c49cd66dd1304c009e81e14b0845 Mon Sep 17 00:00:00 2001 From: wolflu05 <76838159+wolflu05@users.noreply.github.com> Date: Fri, 19 May 2023 08:15:37 +0000 Subject: [PATCH 08/86] Added error handeling for driver and machine type --- InvenTree/machine/admin.py | 1 + InvenTree/machine/machine_type.py | 57 +++++++++++++++---- .../machine_types/LabelPrintingMachineType.py | 2 + InvenTree/machine/models.py | 8 ++- InvenTree/machine/registry.py | 13 +++++ 5 files changed, 68 insertions(+), 13 deletions(-) diff --git a/InvenTree/machine/admin.py b/InvenTree/machine/admin.py index 136dc63ca9e..e19796b16f6 100755 --- a/InvenTree/machine/admin.py +++ b/InvenTree/machine/admin.py @@ -41,4 +41,5 @@ class MachineAdmin(admin.ModelAdmin): form = MachineAdminForm list_filter = ["active"] list_display = ["name", "machine_type_key", "driver_key", "active", "is_driver_available", "no_errors"] + readonly_fields = ["is_driver_available", "get_admin_errors"] inlines = [MachineSettingInline] diff --git a/InvenTree/machine/machine_type.py b/InvenTree/machine/machine_type.py index 40a78510d8a..04118edc2e0 100644 --- a/InvenTree/machine/machine_type.py +++ b/InvenTree/machine/machine_type.py @@ -1,4 +1,15 @@ -from typing import Dict +from typing import TYPE_CHECKING, Dict + +# Import only for typechecking, otherwise this throws cyclic import errors +if TYPE_CHECKING: + from common.models import SettingsKeyType + from machine.models import Machine +else: # pragma: no cover + class Machine: + pass + + class SettingsKeyType: + pass class BaseDriver: @@ -12,18 +23,30 @@ class BaseDriver: SLUG: str NAME: str - DESCRIPTION: str = "" + DESCRIPTION: str + + MACHINE_SETTINGS: Dict[str, SettingsKeyType] + + @classmethod + def validate(cls): + def attribute_missing(key): + return not hasattr(cls, key) or getattr(cls, key) == "" + + def override_missing(base_implementation): + return base_implementation == getattr(cls, base_implementation.__name__, None) - # Import only for typechecking - from typing import TYPE_CHECKING - if TYPE_CHECKING: - from machine.models import Machine - else: # pragma: no cover - class Machine: - pass + missing_attributes = list(filter(attribute_missing, ["SLUG", "NAME", "DESCRIPTION"])) + missing_overrides = list(filter(override_missing, getattr(cls, "requires_override", []))) - # TODO: add better typing - MACHINE_SETTINGS: Dict[str, dict] + errors = [] + + if len(missing_attributes) > 0: + errors.append(f"did not provide the following attributes: {', '.join(missing_attributes)}") + if len(missing_overrides) > 0: + errors.append(f"did not override the required attributes: {', '.join(map(lambda attr: attr.__name__, missing_overrides))}") + + if len(errors) > 0: + raise NotImplementedError(f"The driver '{cls}' " + " and ".join(errors)) def init_machine(self, machine: Machine): """This method get called for each active machine using that driver while initialization @@ -53,6 +76,16 @@ class BaseMachineType: SLUG: str NAME: str - DESCRIPTION: str = "" + DESCRIPTION: str base_driver: BaseDriver + + @classmethod + def validate(cls): + def attribute_missing(key): + return not hasattr(cls, key) or getattr(cls, key) == "" + + missing_attributes = list(filter(attribute_missing, ["SLUG", "NAME", "DESCRIPTION", "base_driver"])) + + if len(missing_attributes) > 0: + raise NotImplementedError(f"The machine type '{cls}' did not provide the following attributes: {', '.join(missing_attributes)}") diff --git a/InvenTree/machine/machine_types/LabelPrintingMachineType.py b/InvenTree/machine/machine_types/LabelPrintingMachineType.py index c114b440c98..d8e2cce3392 100644 --- a/InvenTree/machine/machine_types/LabelPrintingMachineType.py +++ b/InvenTree/machine/machine_types/LabelPrintingMachineType.py @@ -14,6 +14,8 @@ def print_labels(): """This function must be overriden.""" raise NotImplementedError("The `print_labels` function must be overriden!") + requires_override = [print_label] + class LabelPrintingMachineType(BaseMachineType): SLUG = "label_printer" diff --git a/InvenTree/machine/models.py b/InvenTree/machine/models.py index 15498290752..989926b9447 100755 --- a/InvenTree/machine/models.py +++ b/InvenTree/machine/models.py @@ -2,6 +2,8 @@ from django.contrib import admin from django.db import models +from django.utils.html import format_html_join +from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ import common.models @@ -54,7 +56,7 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: self.errors.append(f"Driver '{self.driver_key}' not found") if not self.machine_type: self.errors.append(f"Machine type '{self.machine_type_key}' not found") - if self.machine_type and not isinstance(self.driver, self.machine_type.base_driver): + if self.machine_type and self.driver and not isinstance(self.driver, self.machine_type.base_driver): self.errors.append(f"'{self.driver.NAME}' is incompatibe with machine type '{self.machine_type.NAME}'") if len(self.errors) > 0: @@ -72,6 +74,10 @@ def no_errors(self) -> bool: """Status if machine has errors""" return len(self.errors) == 0 + @admin.display(description=_("Errors")) + def get_admin_errors(self): + return format_html_join(mark_safe("
"), "{}", ((str(error),) for error in self.errors)) or mark_safe(f"{_('No errors')}") + def initialize(self): """Machine initialitation function, gets called after all machines are loaded""" if self.driver is None: diff --git a/InvenTree/machine/registry.py b/InvenTree/machine/registry.py index 9b9a8eb50e3..ce6bdf125c9 100644 --- a/InvenTree/machine/registry.py +++ b/InvenTree/machine/registry.py @@ -24,6 +24,7 @@ def __init__(self) -> None: self.machines: Dict[str, Machine] = {} self.base_drivers: List[BaseDriver] = [] + self.errors = [] def initialize(self): print("INITIALIZE") # TODO: remove debug statement @@ -41,6 +42,12 @@ def discover_machine_types(self): discovered_machine_types: List[BaseMachineType] = InvenTree.helpers.inheritors(BaseMachineType) for machine_type in discovered_machine_types: + try: + machine_type.validate() + except NotImplementedError as error: + self.errors.append(error) + continue + machine_types[machine_type.SLUG] = machine_type base_drivers.append(machine_type.base_driver) @@ -62,6 +69,12 @@ def discover_drivers(self): if driver in self.base_drivers: continue + try: + driver.validate() + except NotImplementedError as error: + self.errors.append(error) + continue + drivers[driver.SLUG] = driver self.drivers = drivers From fd68eac8fe99a6253e65721bb1af2f906b8b8529 Mon Sep 17 00:00:00 2001 From: wolflu05 <76838159+wolflu05@users.noreply.github.com> Date: Fri, 19 May 2023 08:24:55 +0000 Subject: [PATCH 09/86] Extended get_machines functionality --- InvenTree/machine/machine_type.py | 6 +++--- InvenTree/machine/registry.py | 8 +++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/InvenTree/machine/machine_type.py b/InvenTree/machine/machine_type.py index 04118edc2e0..5d49fefde48 100644 --- a/InvenTree/machine/machine_type.py +++ b/InvenTree/machine/machine_type.py @@ -56,11 +56,11 @@ def init_machine(self, machine: Machine): """ pass - def get_machines(self): - """Return all machines using this driver.""" + def get_machines(self, **kwargs): + """Return all machines using this driver. (By default only active machines)""" from machine import registry - return registry.get_machines(driver=self) + return registry.get_machines(driver=self, **kwargs) class BaseMachineType: diff --git a/InvenTree/machine/registry.py b/InvenTree/machine/registry.py index ce6bdf125c9..a402c00e467 100644 --- a/InvenTree/machine/registry.py +++ b/InvenTree/machine/registry.py @@ -103,17 +103,19 @@ def load_machines(self): machine.initialize() def get_machines(self, **kwargs): - """Get loaded machines from registry. + """Get loaded machines from registry. (By default only active machines) Kwargs: name: Machine name - machine_type: Machine type defition (class) + machine_type: Machine type definition (class) driver: Machine driver (class) - active: (bool) + active: (bool, default: True) base_driver: base driver (class | List[class]) """ allowed_fields = ["name", "machine_type", "driver", "active", "base_driver"] + kwargs = {**{'active': True}, **kwargs} + def filter_machine(machine): for key, value in kwargs.items(): if key not in allowed_fields: From 5c7373987192e2f01065b264d9104e2f96c35d25 Mon Sep 17 00:00:00 2001 From: wolflu05 <76838159+wolflu05@users.noreply.github.com> Date: Fri, 19 May 2023 11:32:48 +0000 Subject: [PATCH 10/86] Export everything from plugin module --- InvenTree/machine/__init__.py | 6 +++++- InvenTree/machine/machine_types/__init__.py | 8 ++++++-- InvenTree/plugin/machine/__init__.py | 9 +++++++++ 3 files changed, 20 insertions(+), 3 deletions(-) create mode 100644 InvenTree/plugin/machine/__init__.py diff --git a/InvenTree/machine/__init__.py b/InvenTree/machine/__init__.py index 3677f302bf6..4268fc35da6 100755 --- a/InvenTree/machine/__init__.py +++ b/InvenTree/machine/__init__.py @@ -1,5 +1,9 @@ +from machine.machine_type import BaseDriver, BaseMachineType from machine.registry import registry __all__ = [ - "registry" + "registry", + + "BaseMachineType", + "BaseDriver", ] diff --git a/InvenTree/machine/machine_types/__init__.py b/InvenTree/machine/machine_types/__init__.py index 8fc4f82fba0..263e2060ef9 100644 --- a/InvenTree/machine/machine_types/__init__.py +++ b/InvenTree/machine/machine_types/__init__.py @@ -1,6 +1,10 @@ -from machine.machine_types.LabelPrintingMachineType import \ - BaseLabelPrintingDriver +from machine.machine_types.LabelPrintingMachineType import ( + BaseLabelPrintingDriver, LabelPrintingMachineType) __all__ = [ + # machine types + "LabelPrintingMachineType", + + # base drivers "BaseLabelPrintingDriver", ] diff --git a/InvenTree/plugin/machine/__init__.py b/InvenTree/plugin/machine/__init__.py new file mode 100644 index 00000000000..180648399fc --- /dev/null +++ b/InvenTree/plugin/machine/__init__.py @@ -0,0 +1,9 @@ +from machine import BaseDriver, BaseMachineType, machine_types, registry +from machine.machine_types import * # noqa: F403, F401 + +__all__ = [ + "registry", + "BaseDriver", + "BaseMachineType", + *machine_types.__all__ +] From 67db09fb26657e815a062fc3f3216905ef4fedd4 Mon Sep 17 00:00:00 2001 From: wolflu05 <76838159+wolflu05@users.noreply.github.com> Date: Fri, 26 May 2023 15:02:44 +0000 Subject: [PATCH 11/86] Fix spelling mistakes --- InvenTree/machine/models.py | 13 ++++++------- InvenTree/machine/registry.py | 6 +++++- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/InvenTree/machine/models.py b/InvenTree/machine/models.py index 989926b9447..283274cbf16 100755 --- a/InvenTree/machine/models.py +++ b/InvenTree/machine/models.py @@ -43,7 +43,7 @@ def __str__(self) -> str: return f"{self.name}" def __init__(self, *args: Any, **kwargs: Any) -> None: - """Override to set origignal state of the machine instance.""" + """Override to set original state of the machine instance.""" super().__init__(*args, **kwargs) self.errors = [] @@ -57,7 +57,7 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: if not self.machine_type: self.errors.append(f"Machine type '{self.machine_type_key}' not found") if self.machine_type and self.driver and not isinstance(self.driver, self.machine_type.base_driver): - self.errors.append(f"'{self.driver.NAME}' is incompatibe with machine type '{self.machine_type.NAME}'") + self.errors.append(f"'{self.driver.NAME}' is incompatible with machine type '{self.machine_type.NAME}'") if len(self.errors) > 0: return @@ -79,7 +79,7 @@ def get_admin_errors(self): return format_html_join(mark_safe("
"), "{}", ((str(error),) for error in self.errors)) or mark_safe(f"{_('No errors')}") def initialize(self): - """Machine initialitation function, gets called after all machines are loaded""" + """Machine initialization function, gets called after all machines are loaded""" if self.driver is None: return @@ -98,21 +98,20 @@ def get_setting(self, key, cache=False): return MachineSetting.get_setting(key, machine=self, cache=cache) - def set_setting(self, key, value, user=None): + def set_setting(self, key, value): """Set plugin setting value by key. Arguments: key: The 'name' of the setting to set value: The 'value' of the setting - user: TODO: what is this doing? """ from machine.models import MachineSetting - MachineSetting.set_setting(key, value, user, machine=self) + MachineSetting.set_setting(key, value, None, machine=self) class MachineSetting(common.models.BaseInvenTreeSetting): - """This models represents settings for individial machines.""" + """This models represents settings for individual machines.""" typ = "machine" extra_unique_fields = ["machine"] diff --git a/InvenTree/machine/registry.py b/InvenTree/machine/registry.py index a402c00e467..3a40bf6ad7b 100644 --- a/InvenTree/machine/registry.py +++ b/InvenTree/machine/registry.py @@ -100,7 +100,11 @@ def load_machines(self): # initialize machines after all machine instances were created for machine in self.machines.values(): - machine.initialize() + try: + machine.initialize() + except Exception: + # TODO: handle exception + pass def get_machines(self, **kwargs): """Get loaded machines from registry. (By default only active machines) From 4767439c309e82be7ff3ccbe18c83771a393758b Mon Sep 17 00:00:00 2001 From: wolflu05 <76838159+wolflu05@users.noreply.github.com> Date: Fri, 26 May 2023 22:07:01 +0000 Subject: [PATCH 12/86] Better states handeling, BaseMachineType is now used instead of Machine Model --- InvenTree/machine/admin.py | 37 +++++- InvenTree/machine/machine_type.py | 122 +++++++++++++++---- InvenTree/machine/migrations/0001_initial.py | 8 +- InvenTree/machine/models.py | 90 ++++---------- InvenTree/machine/registry.py | 38 +++--- 5 files changed, 183 insertions(+), 112 deletions(-) diff --git a/InvenTree/machine/admin.py b/InvenTree/machine/admin.py index e19796b16f6..063cc4834b4 100755 --- a/InvenTree/machine/admin.py +++ b/InvenTree/machine/admin.py @@ -6,7 +6,7 @@ from machine.registry import registry -class MachineAdminForm(forms.ModelForm): +class MachineConfigAdminForm(forms.ModelForm): def get_machine_type_choices(): return [(machine_type.SLUG, machine_type.NAME) for machine_type in registry.machine_types.values()] @@ -29,17 +29,44 @@ class MachineSettingInline(admin.TabularInline): 'key', ] + def get_extra(self, request, obj, **kwargs): + if getattr(obj, 'machine', None) is not None: + # TODO: improve this mechanism + settings = getattr(obj.machine.driver, "MACHINE_SETTINGS", {}) + count = len(settings.keys()) + if obj.settings.count() != count: + return count + return 0 + def has_add_permission(self, request, obj): """The machine settings should not be meddled with manually.""" - return False + return True -@admin.register(models.Machine) -class MachineAdmin(admin.ModelAdmin): +@admin.register(models.MachineConfig) +class MachineConfigAdmin(admin.ModelAdmin): """Custom admin with restricted id fields.""" - form = MachineAdminForm + form = MachineConfigAdminForm list_filter = ["active"] list_display = ["name", "machine_type_key", "driver_key", "active", "is_driver_available", "no_errors"] readonly_fields = ["is_driver_available", "get_admin_errors"] inlines = [MachineSettingInline] + + def get_readonly_fields(self, request, obj): + # if update, don't allow changes on machine_type and driver + if obj is not None: + return ["machine_type_key", "driver_key", *self.readonly_fields] + + return self.readonly_fields + + def get_inline_formsets(self, request, formsets, inline_instances, obj): + formsets = super().get_inline_formsets(request, formsets, inline_instances, obj) + + if getattr(obj, 'machine', None) is not None: + settings = getattr(obj.machine.driver, "MACHINE_SETTINGS", {}) + for form, setting in zip(formsets[0].forms, settings.keys()): + if form.fields["key"].initial is None: + form.fields["key"].initial = setting + + return formsets diff --git a/InvenTree/machine/machine_type.py b/InvenTree/machine/machine_type.py index 5d49fefde48..50eda1b4cfa 100644 --- a/InvenTree/machine/machine_type.py +++ b/InvenTree/machine/machine_type.py @@ -3,29 +3,26 @@ # Import only for typechecking, otherwise this throws cyclic import errors if TYPE_CHECKING: from common.models import SettingsKeyType - from machine.models import Machine + from machine.models import MachineConfig else: # pragma: no cover - class Machine: + class MachineConfig: pass class SettingsKeyType: pass -class BaseDriver: - """Base class for machine drivers +# TODO: move to better destination +class ClassValidationMixin: + """Mixin to validate class attributes and overrides. - Attributes: - SLUG: Slug string for identifying a machine - NAME: User friendly name for displaying - DESCRIPTION: Description of what this driver does (default: "") + Class attributes: + required_attributes: List of class attributes that need to be defined + required_overrides: List of functions that need override """ - SLUG: str - NAME: str - DESCRIPTION: str - - MACHINE_SETTINGS: Dict[str, SettingsKeyType] + required_attributes = [] + required_overrides = [] @classmethod def validate(cls): @@ -35,8 +32,8 @@ def attribute_missing(key): def override_missing(base_implementation): return base_implementation == getattr(cls, base_implementation.__name__, None) - missing_attributes = list(filter(attribute_missing, ["SLUG", "NAME", "DESCRIPTION"])) - missing_overrides = list(filter(override_missing, getattr(cls, "requires_override", []))) + missing_attributes = list(filter(attribute_missing, cls.required_attributes)) + missing_overrides = list(filter(override_missing, cls.required_overrides)) errors = [] @@ -46,9 +43,27 @@ def override_missing(base_implementation): errors.append(f"did not override the required attributes: {', '.join(map(lambda attr: attr.__name__, missing_overrides))}") if len(errors) > 0: - raise NotImplementedError(f"The driver '{cls}' " + " and ".join(errors)) + raise NotImplementedError(f"'{cls}' " + " and ".join(errors)) + - def init_machine(self, machine: Machine): +class BaseDriver(ClassValidationMixin): + """Base class for machine drivers + + Attributes: + SLUG: Slug string for identifying a machine + NAME: User friendly name for displaying + DESCRIPTION: Description of what this driver does + + MACHINE_SETTINGS: Settings dict (optional) + """ + + SLUG: str + NAME: str + DESCRIPTION: str + + MACHINE_SETTINGS: Dict[str, SettingsKeyType] + + def init_machine(self, machine: "BaseMachineType"): """This method get called for each active machine using that driver while initialization Arguments: @@ -63,7 +78,7 @@ def get_machines(self, **kwargs): return registry.get_machines(driver=self, **kwargs) -class BaseMachineType: +class BaseMachineType(ClassValidationMixin): """Base class for machine types Attributes: @@ -80,12 +95,69 @@ class BaseMachineType: base_driver: BaseDriver - @classmethod - def validate(cls): - def attribute_missing(key): - return not hasattr(cls, key) or getattr(cls, key) == "" + # used by the ClassValidationMixin + required_attributes = ["SLUG", "NAME", "DESCRIPTION", "base_driver"] - missing_attributes = list(filter(attribute_missing, ["SLUG", "NAME", "DESCRIPTION", "base_driver"])) + def __init__(self, machine_config: MachineConfig) -> None: + from machine import registry - if len(missing_attributes) > 0: - raise NotImplementedError(f"The machine type '{cls}' did not provide the following attributes: {', '.join(missing_attributes)}") + self.errors = [] + self.initialized = False + + self.machine_config = machine_config + self.driver = registry.get_driver_instance(self.machine_config.driver_key) + + if not self.driver: + self.errors.append(f"Driver '{self.machine_config.driver_key}' not found") + if self.driver and not isinstance(self.driver, self.base_driver): + self.errors.append(f"'{self.driver.NAME}' is incompatible with machine type '{self.NAME}'") + + if len(self.errors) > 0: + return + + # TODO: add further init stuff here + + @property + def pk(self): + return self.machine_config.pk + + @property + def name(self): + return self.machine_config.name + + @property + def active(self): + return self.machine_config.active + + def initialize(self): + """Machine initialization function, gets called after all machines are loaded""" + if self.driver is None: + return + + try: + self.driver.init_machine(self) + self.initialized = True + except Exception as e: + self.errors.append(e) + + def get_setting(self, key, cache=False): + """Return the 'value' of the setting associated with this machine. + + Arguments: + key: The 'name' of the setting value to be retrieved + cache: Whether to use RAM cached value (default = False) + """ + from machine.models import MachineSetting + + return MachineSetting.get_setting(key, machine_config=self.machine_config, cache=cache) + + def set_setting(self, key, value): + """Set plugin setting value by key. + + Arguments: + key: The 'name' of the setting to set + value: The 'value' of the setting + """ + from machine.models import MachineSetting + + MachineSetting.set_setting(key, value, None, machine_config=self.machine_config) diff --git a/InvenTree/machine/migrations/0001_initial.py b/InvenTree/machine/migrations/0001_initial.py index b2ac5c70de4..6ee6fcd6c74 100644 --- a/InvenTree/machine/migrations/0001_initial.py +++ b/InvenTree/machine/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 3.2.19 on 2023-05-14 18:39 +# Generated by Django 3.2.19 on 2023-05-26 19:21 from django.db import migrations, models import django.db.models.deletion @@ -13,7 +13,7 @@ class Migration(migrations.Migration): operations = [ migrations.CreateModel( - name='Machine', + name='MachineConfig', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('name', models.CharField(help_text='Name of machine', max_length=255, unique=True, verbose_name='Name')), @@ -28,10 +28,10 @@ class Migration(migrations.Migration): ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('key', models.CharField(help_text='Settings key (must be unique - case insensitive)', max_length=50)), ('value', models.CharField(blank=True, help_text='Settings value', max_length=200)), - ('machine', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='settings', to='machine.machine', verbose_name='Machine')), + ('machine_config', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='settings', to='machine.machineconfig', verbose_name='Machine Config')), ], options={ - 'unique_together': {('machine', 'key')}, + 'unique_together': {('machine_config', 'key')}, }, ), ] diff --git a/InvenTree/machine/models.py b/InvenTree/machine/models.py index 283274cbf16..46b8a869b4d 100755 --- a/InvenTree/machine/models.py +++ b/InvenTree/machine/models.py @@ -1,5 +1,3 @@ -from typing import Any - from django.contrib import admin from django.db import models from django.utils.html import format_html_join @@ -7,10 +5,10 @@ from django.utils.translation import gettext_lazy as _ import common.models -from machine.registry import registry +from machine import registry -class Machine(models.Model): +class MachineConfig(models.Model): """A Machine objects represents a physical machine.""" name = models.CharField( @@ -42,32 +40,29 @@ def __str__(self) -> str: """String representation of a machine.""" return f"{self.name}" - def __init__(self, *args: Any, **kwargs: Any) -> None: - """Override to set original state of the machine instance.""" - super().__init__(*args, **kwargs) + def save(self, *args, **kwargs) -> None: + created = self.pk is None - self.errors = [] - self.initialized = False + super().save(*args, **kwargs) - self.driver = registry.get_driver_instance(self.driver_key) - self.machine_type = registry.machine_types.get(self.machine_type_key, None) + # TODO: active state - if not self.driver: - self.errors.append(f"Driver '{self.driver_key}' not found") - if not self.machine_type: - self.errors.append(f"Machine type '{self.machine_type_key}' not found") - if self.machine_type and self.driver and not isinstance(self.driver, self.machine_type.base_driver): - self.errors.append(f"'{self.driver.NAME}' is incompatible with machine type '{self.machine_type.NAME}'") + # machine was created, add it to the machine registry + if created: + registry.add_machine(self, initialize=True) - if len(self.errors) > 0: - return + @property + def machine(self): + return registry.get_machine(self.pk) - # TODO: add other init stuff here + @property + def errors(self): + return self.machine.errors if self.machine else [] @admin.display(boolean=True, description=_("Driver available")) def is_driver_available(self) -> bool: """Status if driver for machine is available""" - return self.driver is not None + return self.machine and self.machine.driver is not None @admin.display(boolean=True, description=_("Machine has no errors")) def no_errors(self) -> bool: @@ -78,54 +73,23 @@ def no_errors(self) -> bool: def get_admin_errors(self): return format_html_join(mark_safe("
"), "{}", ((str(error),) for error in self.errors)) or mark_safe(f"{_('No errors')}") - def initialize(self): - """Machine initialization function, gets called after all machines are loaded""" - if self.driver is None: - return - - self.driver.init_machine(self) - - self.initialized = True - - def get_setting(self, key, cache=False): - """Return the 'value' of the setting associated with this machine. - - Arguments: - key: The 'name' of the setting value to be retrieved - cache: Whether to use RAM cached value (default = False) - """ - from machine.models import MachineSetting - - return MachineSetting.get_setting(key, machine=self, cache=cache) - - def set_setting(self, key, value): - """Set plugin setting value by key. - - Arguments: - key: The 'name' of the setting to set - value: The 'value' of the setting - """ - from machine.models import MachineSetting - - MachineSetting.set_setting(key, value, None, machine=self) - class MachineSetting(common.models.BaseInvenTreeSetting): """This models represents settings for individual machines.""" - typ = "machine" - extra_unique_fields = ["machine"] + typ = "machine_config" + extra_unique_fields = ["machine_config"] class Meta: """Meta for MachineSetting.""" unique_together = [ - ("machine", "key") + ("machine_config", "key") ] - machine = models.ForeignKey( - Machine, + machine_config = models.ForeignKey( + MachineConfig, related_name="settings", - verbose_name=_("Machine"), + verbose_name=_("Machine Config"), on_delete=models.CASCADE ) @@ -135,15 +99,15 @@ def get_setting_definition(cls, key, **kwargs): is a dict object that fully defines all the setting parameters. Here, unlike the BaseInvenTreeSetting, we do not know the definitions of all settings - 'ahead of time' (as they are defined externally in the machine). + 'ahead of time' (as they are defined externally in the machine driver). Settings can be provided by the caller, as kwargs['settings']. - If not provided, we'll look at the machine registry to see what settings this machine requires + If not provided, we'll look at the machine registry to see what settings this machine driver requires """ if 'settings' not in kwargs: - machine: Machine = kwargs.pop('machine', None) - if machine: - kwargs['settings'] = getattr(machine.driver, "MACHINE_SETTINGS", {}) + machine_config: MachineConfig = kwargs.pop('machine_config', None) + if machine_config: + kwargs['settings'] = getattr(machine_config.machine.driver, "MACHINE_SETTINGS", {}) return super().get_setting_definition(key, **kwargs) diff --git a/InvenTree/machine/registry.py b/InvenTree/machine/registry.py index 3a40bf6ad7b..1978199e12a 100644 --- a/InvenTree/machine/registry.py +++ b/InvenTree/machine/registry.py @@ -13,15 +13,10 @@ def __init__(self) -> None: Set up all needed references for internal and external states. """ - # Import only for typechecking - from typing import TYPE_CHECKING - if TYPE_CHECKING: - from machine.models import Machine - self.machine_types: Dict[str, BaseMachineType] = {} self.drivers: Dict[str, BaseDriver] = {} self.driver_instances: Dict[str, BaseDriver] = {} - self.machines: Dict[str, Machine] = {} + self.machines: Dict[str, BaseMachineType] = {} self.base_drivers: List[BaseDriver] = [] self.errors = [] @@ -93,18 +88,26 @@ def get_driver_instance(self, slug: str): def load_machines(self): # Imports need to be in this level to prevent early db model imports - from machine.models import Machine + from machine.models import MachineConfig - for machine in Machine.objects.all(): - self.machines[machine.pk] = machine + for machine_config in MachineConfig.objects.all(): + self.add_machine(machine_config, initialize=False) # initialize machines after all machine instances were created for machine in self.machines.values(): - try: - machine.initialize() - except Exception: - # TODO: handle exception - pass + machine.initialize() + + def add_machine(self, machine_config, initialize=True): + machine_type = self.machine_types.get(machine_config.machine_type_key, None) + if machine_type is None: + self.errors.append(f"Machine type '{machine_config.machine_type_key}' not found") + return + + machine: BaseMachineType = machine_type(machine_config) + self.machines[machine.pk] = machine + + if initialize: + machine.initialize() def get_machines(self, **kwargs): """Get loaded machines from registry. (By default only active machines) @@ -120,7 +123,7 @@ def get_machines(self, **kwargs): kwargs = {**{'active': True}, **kwargs} - def filter_machine(machine): + def filter_machine(machine: BaseMachineType): for key, value in kwargs.items(): if key not in allowed_fields: continue @@ -130,6 +133,11 @@ def filter_machine(machine): if machine.driver and not issubclass(machine.driver.__class__, value): return False + # check if current machine is subclass from machine_type + elif key == "machine_type": + if issubclass(machine.__class__, value): + return False + # check attributes of machine elif value != getattr(machine, key, None): return False From 595ccd6a2a9cb662db18bf6bbdd6aa83330bf83f Mon Sep 17 00:00:00 2001 From: wolflu05 <76838159+wolflu05@users.noreply.github.com> Date: Fri, 26 May 2023 22:51:45 +0000 Subject: [PATCH 13/86] Use uuid as pk --- InvenTree/machine/migrations/0001_initial.py | 5 +++-- InvenTree/machine/models.py | 10 +++++++++- InvenTree/machine/registry.py | 9 +++++---- 3 files changed, 17 insertions(+), 7 deletions(-) diff --git a/InvenTree/machine/migrations/0001_initial.py b/InvenTree/machine/migrations/0001_initial.py index 6ee6fcd6c74..e48259df9c4 100644 --- a/InvenTree/machine/migrations/0001_initial.py +++ b/InvenTree/machine/migrations/0001_initial.py @@ -1,7 +1,8 @@ -# Generated by Django 3.2.19 on 2023-05-26 19:21 +# Generated by Django 3.2.19 on 2023-05-26 22:21 from django.db import migrations, models import django.db.models.deletion +import uuid class Migration(migrations.Migration): @@ -15,7 +16,7 @@ class Migration(migrations.Migration): migrations.CreateModel( name='MachineConfig', fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), ('name', models.CharField(help_text='Name of machine', max_length=255, unique=True, verbose_name='Name')), ('machine_type_key', models.CharField(help_text='Type of machine', max_length=255, verbose_name='Machine Type')), ('driver_key', models.CharField(help_text='Driver used for the machine', max_length=255, verbose_name='Driver')), diff --git a/InvenTree/machine/models.py b/InvenTree/machine/models.py index 46b8a869b4d..56625dca6e6 100755 --- a/InvenTree/machine/models.py +++ b/InvenTree/machine/models.py @@ -1,3 +1,5 @@ +import uuid + from django.contrib import admin from django.db import models from django.utils.html import format_html_join @@ -11,6 +13,8 @@ class MachineConfig(models.Model): """A Machine objects represents a physical machine.""" + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + name = models.CharField( unique=True, max_length=255, @@ -41,7 +45,7 @@ def __str__(self) -> str: return f"{self.name}" def save(self, *args, **kwargs) -> None: - created = self.pk is None + created = self._state.adding super().save(*args, **kwargs) @@ -51,6 +55,10 @@ def save(self, *args, **kwargs) -> None: if created: registry.add_machine(self, initialize=True) + def delete(self, using, keep_parents): + # TODO: remove from registry + return super().delete(using, keep_parents) + @property def machine(self): return registry.get_machine(self.pk) diff --git a/InvenTree/machine/registry.py b/InvenTree/machine/registry.py index 1978199e12a..569ccb81725 100644 --- a/InvenTree/machine/registry.py +++ b/InvenTree/machine/registry.py @@ -1,5 +1,6 @@ import logging -from typing import Dict, List +from typing import Dict, List, Union +from uuid import UUID from machine.machine_type import BaseDriver, BaseMachineType @@ -104,7 +105,7 @@ def add_machine(self, machine_config, initialize=True): return machine: BaseMachineType = machine_type(machine_config) - self.machines[machine.pk] = machine + self.machines[str(machine.pk)] = machine if initialize: machine.initialize() @@ -146,9 +147,9 @@ def filter_machine(machine: BaseMachineType): return list(filter(filter_machine, self.machines.values())) - def get_machine(self, pk): + def get_machine(self, pk: Union[str, UUID]): """Get machine from registry by pk.""" - return self.machines.get(pk, None) + return self.machines.get(str(pk), None) registry: MachinesRegistry = MachinesRegistry() From 2f8ad363974741f174bb6873151e69e36b40b868 Mon Sep 17 00:00:00 2001 From: wolflu05 <76838159+wolflu05@users.noreply.github.com> Date: Sat, 27 May 2023 19:27:10 +0000 Subject: [PATCH 14/86] WIP: machine termination hook --- InvenTree/InvenTree/ready.py | 13 +++++++++++++ InvenTree/InvenTree/wsgi.py | 18 +++++++++++++++++- InvenTree/machine/apps.py | 21 ++++++++++++++++++--- InvenTree/machine/machine_type.py | 25 +++++++++++++++++++++++++ InvenTree/machine/registry.py | 16 ++++++++++++++-- 5 files changed, 87 insertions(+), 6 deletions(-) diff --git a/InvenTree/InvenTree/ready.py b/InvenTree/InvenTree/ready.py index 18dba1f4d56..d5998378d62 100644 --- a/InvenTree/InvenTree/ready.py +++ b/InvenTree/InvenTree/ready.py @@ -1,5 +1,6 @@ """Functions to check if certain parts of InvenTree are ready.""" +import os import sys @@ -13,6 +14,18 @@ def isImportingData(): return 'loaddata' in sys.argv +def isInMainThread(): + """Django starts two processes, one for the actual dev server and the other to reload the application. + + The RUN_MAIN env is set in that case. However if --noreload is applied, this variable + is not set because there are no different threads. + """ + if '--noreload' in sys.argv: + return True + + return os.environ.get('RUN_MAIN', None) == 'true' + + def canAppAccessDatabase(allow_test: bool = False, allow_plugins: bool = False, allow_shell: bool = False): """Returns True if the apps.py file can access database records. diff --git a/InvenTree/InvenTree/wsgi.py b/InvenTree/InvenTree/wsgi.py index dfced329a8a..33798408b07 100644 --- a/InvenTree/InvenTree/wsgi.py +++ b/InvenTree/InvenTree/wsgi.py @@ -6,10 +6,26 @@ https://docs.djangoproject.com/en/1.10/howto/deployment/wsgi/ """ -import os # pragma: no cover +import os +import signal # pragma: no cover +import sys from django.core.wsgi import get_wsgi_application # pragma: no cover +from django.dispatch import Signal os.environ.setdefault("DJANGO_SETTINGS_MODULE", "InvenTree.settings") # pragma: no cover application = get_wsgi_application() # pragma: no cover + +# Shutdown signal +# Ref: https://stackoverflow.com/questions/15472075/django-framework-is-there-a-shutdown-event-that-can-be-subscribed-to +shutdown_signal = Signal() + + +def _forward_to_django_shutdown_signal(signal, frame): + shutdown_signal.send('system') + print("AFTER SIGNALS SEND") + sys.exit(0) # So runserver does try to exit + + +signal.signal(signal.SIGINT, _forward_to_django_shutdown_signal) diff --git a/InvenTree/machine/apps.py b/InvenTree/machine/apps.py index 545260e1359..81b545f24e5 100755 --- a/InvenTree/machine/apps.py +++ b/InvenTree/machine/apps.py @@ -2,7 +2,8 @@ from django.apps import AppConfig -from InvenTree.ready import canAppAccessDatabase, isPluginRegistryLoaded +from InvenTree.ready import (canAppAccessDatabase, isInMainThread, + isPluginRegistryLoaded) logger = logging.getLogger('inventree') @@ -12,11 +13,25 @@ class MachineConfig(AppConfig): def ready(self) -> None: """Initialization method for the Machine app.""" - if not canAppAccessDatabase(allow_test=True) or not isPluginRegistryLoaded(): + if not canAppAccessDatabase(allow_test=True) or not isPluginRegistryLoaded() or not isInMainThread(): logger.debug("Machine app: Skipping machine loading sequence") return - from machine.registry import registry + from machine import registry logger.info("Loading InvenTree machines") registry.initialize() + + from InvenTree.wsgi import shutdown_signal + shutdown_signal.connect(self.on_shutdown, dispatch_uid="machine.on_shutdown") + + def on_shutdown(self, **kwargs): + # gracefully shutdown machines + from machine import registry + + logger.debug("Shutting down InvenTree machines") + registry.terminate() + + import time + time.sleep(3) + print("### After sleep") diff --git a/InvenTree/machine/machine_type.py b/InvenTree/machine/machine_type.py index 50eda1b4cfa..de72ad8347a 100644 --- a/InvenTree/machine/machine_type.py +++ b/InvenTree/machine/machine_type.py @@ -71,6 +71,14 @@ def init_machine(self, machine: "BaseMachineType"): """ pass + def terminate_machine(self, machine: "BaseMachineType"): + """This method get called for each machine before server termination or this machine gets removed. + + Arguments: + machine: Machine instance + """ + pass + def get_machines(self, **kwargs): """Return all machines using this driver. (By default only active machines)""" from machine import registry @@ -117,6 +125,10 @@ def __init__(self, machine_config: MachineConfig) -> None: # TODO: add further init stuff here + def __str__(self): + return f"{self.name}" + + # --- properties @property def pk(self): return self.machine_config.pk @@ -129,6 +141,7 @@ def name(self): def active(self): return self.machine_config.active + # --- hook functions def initialize(self): """Machine initialization function, gets called after all machines are loaded""" if self.driver is None: @@ -140,6 +153,18 @@ def initialize(self): except Exception as e: self.errors.append(e) + def terminate(self): + """Machine termination function, gets called before server is shut down or machine gets removed.""" + if self.driver is None: + return + + try: + self.driver.terminate_machine(self) + self.initialized = False + except Exception as e: + self.errors.append(e) + + # --- helper functions def get_setting(self, key, cache=False): """Return the 'value' of the setting associated with this machine. diff --git a/InvenTree/machine/registry.py b/InvenTree/machine/registry.py index 569ccb81725..83a06cf3f4b 100644 --- a/InvenTree/machine/registry.py +++ b/InvenTree/machine/registry.py @@ -28,6 +28,11 @@ def initialize(self): self.discover_drivers() self.load_machines() + def terminate(self): + for machine in self.machines.values(): + if machine.active: + machine.terminate() + def discover_machine_types(self): import InvenTree.helpers @@ -96,7 +101,8 @@ def load_machines(self): # initialize machines after all machine instances were created for machine in self.machines.values(): - machine.initialize() + if machine.active: + machine.initialize() def add_machine(self, machine_config, initialize=True): machine_type = self.machine_types.get(machine_config.machine_type_key, None) @@ -107,9 +113,15 @@ def add_machine(self, machine_config, initialize=True): machine: BaseMachineType = machine_type(machine_config) self.machines[str(machine.pk)] = machine - if initialize: + if initialize and machine.active: machine.initialize() + def remove_machine(self, machine: BaseMachineType): + if machine.active: + machine.terminate() + + self.machines.pop(machine.pk, None) + def get_machines(self, **kwargs): """Get loaded machines from registry. (By default only active machines) From 6bb0ba719bef6f61f4395823f2ff8ee8f7097a3e Mon Sep 17 00:00:00 2001 From: wolflu05 <76838159+wolflu05@users.noreply.github.com> Date: Tue, 30 May 2023 06:33:06 +0000 Subject: [PATCH 15/86] Remove termination hook as this does not work with gunicorn --- InvenTree/InvenTree/wsgi.py | 18 +----------------- InvenTree/machine/apps.py | 14 -------------- InvenTree/machine/machine_type.py | 19 ------------------- InvenTree/machine/registry.py | 8 -------- 4 files changed, 1 insertion(+), 58 deletions(-) diff --git a/InvenTree/InvenTree/wsgi.py b/InvenTree/InvenTree/wsgi.py index 33798408b07..dfced329a8a 100644 --- a/InvenTree/InvenTree/wsgi.py +++ b/InvenTree/InvenTree/wsgi.py @@ -6,26 +6,10 @@ https://docs.djangoproject.com/en/1.10/howto/deployment/wsgi/ """ -import os -import signal # pragma: no cover -import sys +import os # pragma: no cover from django.core.wsgi import get_wsgi_application # pragma: no cover -from django.dispatch import Signal os.environ.setdefault("DJANGO_SETTINGS_MODULE", "InvenTree.settings") # pragma: no cover application = get_wsgi_application() # pragma: no cover - -# Shutdown signal -# Ref: https://stackoverflow.com/questions/15472075/django-framework-is-there-a-shutdown-event-that-can-be-subscribed-to -shutdown_signal = Signal() - - -def _forward_to_django_shutdown_signal(signal, frame): - shutdown_signal.send('system') - print("AFTER SIGNALS SEND") - sys.exit(0) # So runserver does try to exit - - -signal.signal(signal.SIGINT, _forward_to_django_shutdown_signal) diff --git a/InvenTree/machine/apps.py b/InvenTree/machine/apps.py index 81b545f24e5..72c8338773d 100755 --- a/InvenTree/machine/apps.py +++ b/InvenTree/machine/apps.py @@ -21,17 +21,3 @@ def ready(self) -> None: logger.info("Loading InvenTree machines") registry.initialize() - - from InvenTree.wsgi import shutdown_signal - shutdown_signal.connect(self.on_shutdown, dispatch_uid="machine.on_shutdown") - - def on_shutdown(self, **kwargs): - # gracefully shutdown machines - from machine import registry - - logger.debug("Shutting down InvenTree machines") - registry.terminate() - - import time - time.sleep(3) - print("### After sleep") diff --git a/InvenTree/machine/machine_type.py b/InvenTree/machine/machine_type.py index de72ad8347a..45288bd6491 100644 --- a/InvenTree/machine/machine_type.py +++ b/InvenTree/machine/machine_type.py @@ -71,14 +71,6 @@ def init_machine(self, machine: "BaseMachineType"): """ pass - def terminate_machine(self, machine: "BaseMachineType"): - """This method get called for each machine before server termination or this machine gets removed. - - Arguments: - machine: Machine instance - """ - pass - def get_machines(self, **kwargs): """Return all machines using this driver. (By default only active machines)""" from machine import registry @@ -153,17 +145,6 @@ def initialize(self): except Exception as e: self.errors.append(e) - def terminate(self): - """Machine termination function, gets called before server is shut down or machine gets removed.""" - if self.driver is None: - return - - try: - self.driver.terminate_machine(self) - self.initialized = False - except Exception as e: - self.errors.append(e) - # --- helper functions def get_setting(self, key, cache=False): """Return the 'value' of the setting associated with this machine. diff --git a/InvenTree/machine/registry.py b/InvenTree/machine/registry.py index 83a06cf3f4b..a6bad029f01 100644 --- a/InvenTree/machine/registry.py +++ b/InvenTree/machine/registry.py @@ -28,11 +28,6 @@ def initialize(self): self.discover_drivers() self.load_machines() - def terminate(self): - for machine in self.machines.values(): - if machine.active: - machine.terminate() - def discover_machine_types(self): import InvenTree.helpers @@ -117,9 +112,6 @@ def add_machine(self, machine_config, initialize=True): machine.initialize() def remove_machine(self, machine: BaseMachineType): - if machine.active: - machine.terminate() - self.machines.pop(machine.pk, None) def get_machines(self, **kwargs): From 396a4f3f74d8b73c1c29b6ee28f73574a8176c76 Mon Sep 17 00:00:00 2001 From: wolflu05 <76838159+wolflu05@users.noreply.github.com> Date: Tue, 30 May 2023 06:50:22 +0000 Subject: [PATCH 16/86] Remove machine from registry after delete --- InvenTree/machine/models.py | 10 +++++----- InvenTree/machine/registry.py | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/InvenTree/machine/models.py b/InvenTree/machine/models.py index 56625dca6e6..7e4497ee444 100755 --- a/InvenTree/machine/models.py +++ b/InvenTree/machine/models.py @@ -49,15 +49,15 @@ def save(self, *args, **kwargs) -> None: super().save(*args, **kwargs) - # TODO: active state - # machine was created, add it to the machine registry if created: registry.add_machine(self, initialize=True) - def delete(self, using, keep_parents): - # TODO: remove from registry - return super().delete(using, keep_parents) + def delete(self, *args, **kwargs): + # remove machine first from registry + registry.remove_machine(self.machine) + + return super().delete(*args, **kwargs) @property def machine(self): diff --git a/InvenTree/machine/registry.py b/InvenTree/machine/registry.py index a6bad029f01..ce216bcc14e 100644 --- a/InvenTree/machine/registry.py +++ b/InvenTree/machine/registry.py @@ -112,7 +112,7 @@ def add_machine(self, machine_config, initialize=True): machine.initialize() def remove_machine(self, machine: BaseMachineType): - self.machines.pop(machine.pk, None) + self.machines.pop(str(machine.pk), None) def get_machines(self, **kwargs): """Get loaded machines from registry. (By default only active machines) From 8129bd3b43262521cbfe2f6af2d508bc67039dbe Mon Sep 17 00:00:00 2001 From: wolflu05 <76838159+wolflu05@users.noreply.github.com> Date: Tue, 30 May 2023 17:56:41 +0000 Subject: [PATCH 17/86] Added ClassProviderMixin --- InvenTree/machine/machine_type.py | 36 ++++++++++++++++++++++++++++--- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/InvenTree/machine/machine_type.py b/InvenTree/machine/machine_type.py index 45288bd6491..b517e80c22e 100644 --- a/InvenTree/machine/machine_type.py +++ b/InvenTree/machine/machine_type.py @@ -1,5 +1,11 @@ +import inspect +from pathlib import Path from typing import TYPE_CHECKING, Dict +from django.conf import settings + +from plugin import registry as plg_registry + # Import only for typechecking, otherwise this throws cyclic import errors if TYPE_CHECKING: from common.models import SettingsKeyType @@ -12,7 +18,7 @@ class SettingsKeyType: pass -# TODO: move to better destination +# TODO: move to better destination (helpers) class ClassValidationMixin: """Mixin to validate class attributes and overrides. @@ -46,7 +52,31 @@ def override_missing(base_implementation): raise NotImplementedError(f"'{cls}' " + " and ".join(errors)) -class BaseDriver(ClassValidationMixin): +class ClassProviderMixin: + @classmethod + def get_provider_file(cls): + """File that contains the Class definition.""" + return inspect.getfile(cls) + + @classmethod + def get_provider_plugin(cls): + """Plugin that contains the Class definition, otherwise None.""" + for plg in plg_registry.plugins.values(): + if plg.package_path == cls.__module__: + return plg + + @classmethod + def get_is_builtin(cls): + """Is this Class build in the Inventree source code?""" + try: + Path(cls.get_provider_file()).relative_to(settings.BASE_DIR) + return True + except ValueError: + # Path(...).relative_to throws an ValueError if its not relative to the InvenTree source base dir + return False + + +class BaseDriver(ClassValidationMixin, ClassProviderMixin): """Base class for machine drivers Attributes: @@ -78,7 +108,7 @@ def get_machines(self, **kwargs): return registry.get_machines(driver=self, **kwargs) -class BaseMachineType(ClassValidationMixin): +class BaseMachineType(ClassValidationMixin, ClassProviderMixin): """Base class for machine types Attributes: From 21aa0fef7cf7c96656d982e9a205e4080ed066f9 Mon Sep 17 00:00:00 2001 From: wolflu05 <76838159+wolflu05@users.noreply.github.com> Date: Tue, 30 May 2023 18:50:01 +0000 Subject: [PATCH 18/86] Check for slug dupplication --- InvenTree/machine/registry.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/InvenTree/machine/registry.py b/InvenTree/machine/registry.py index ce216bcc14e..779b1c314bb 100644 --- a/InvenTree/machine/registry.py +++ b/InvenTree/machine/registry.py @@ -44,6 +44,10 @@ def discover_machine_types(self): self.errors.append(error) continue + if machine_type.SLUG in machine_types: + self.errors.append(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) @@ -71,6 +75,10 @@ def discover_drivers(self): self.errors.append(error) continue + if driver.SLUG in drivers: + self.errors.append(ValueError(f"Cannot re-register driver '{driver.SLUG}'")) + continue + drivers[driver.SLUG] = driver self.drivers = drivers From 72f5934b37dc19afb762f83215f9954129864ed2 Mon Sep 17 00:00:00 2001 From: wolflu05 <76838159+wolflu05@users.noreply.github.com> Date: Wed, 31 May 2023 21:07:07 +0000 Subject: [PATCH 19/86] Added config_type to MachineSettings to define machine/driver settings --- InvenTree/machine/admin.py | 18 +++++++++---- InvenTree/machine/machine_type.py | 20 +++++++++----- InvenTree/machine/migrations/0001_initial.py | 5 ++-- InvenTree/machine/models.py | 28 +++++++++++++++++--- 4 files changed, 55 insertions(+), 16 deletions(-) diff --git a/InvenTree/machine/admin.py b/InvenTree/machine/admin.py index 063cc4834b4..f7640837306 100755 --- a/InvenTree/machine/admin.py +++ b/InvenTree/machine/admin.py @@ -5,6 +5,8 @@ from machine import models from machine.registry import registry +# Note: Most of this code here is only for developing as there is no UI for machines *yet*. + class MachineConfigAdminForm(forms.ModelForm): def get_machine_type_choices(): @@ -27,20 +29,22 @@ class MachineSettingInline(admin.TabularInline): read_only_fields = [ 'key', + 'config_type' ] def get_extra(self, request, obj, **kwargs): if getattr(obj, 'machine', None) is not None: # TODO: improve this mechanism - settings = getattr(obj.machine.driver, "MACHINE_SETTINGS", {}) - count = len(settings.keys()) + machine_settings = getattr(obj.machine, "MACHINE_SETTINGS", {}) + driver_settings = getattr(obj.machine.driver, "MACHINE_SETTINGS", {}) + count = len(machine_settings.keys()) + len(driver_settings.keys()) if obj.settings.count() != count: return count return 0 def has_add_permission(self, request, obj): """The machine settings should not be meddled with manually.""" - return True + return True # TODO: change back @admin.register(models.MachineConfig) @@ -64,9 +68,13 @@ def get_inline_formsets(self, request, formsets, inline_instances, obj): formsets = super().get_inline_formsets(request, formsets, inline_instances, obj) if getattr(obj, 'machine', None) is not None: - settings = getattr(obj.machine.driver, "MACHINE_SETTINGS", {}) - for form, setting in zip(formsets[0].forms, settings.keys()): + machine_settings = getattr(obj.machine, "MACHINE_SETTINGS", {}) + driver_settings = getattr(obj.machine.driver, "MACHINE_SETTINGS", {}) + settings = [(s, models.MachineSetting.ConfigType.MACHINE) for s in machine_settings] + [(s, models.MachineSetting.ConfigType.DRIVER) for s in driver_settings] + for form, (setting, typ) in zip(formsets[0].forms, settings): if form.fields["key"].initial is None: form.fields["key"].initial = setting + if form.fields["config_type"].initial is None: + form.fields["config_type"].initial = typ return formsets diff --git a/InvenTree/machine/machine_type.py b/InvenTree/machine/machine_type.py index b517e80c22e..cd3b8e7ce31 100644 --- a/InvenTree/machine/machine_type.py +++ b/InvenTree/machine/machine_type.py @@ -1,6 +1,6 @@ import inspect from pathlib import Path -from typing import TYPE_CHECKING, Dict +from typing import TYPE_CHECKING, Dict, Literal from django.conf import settings @@ -84,7 +84,7 @@ class BaseDriver(ClassValidationMixin, ClassProviderMixin): NAME: User friendly name for displaying DESCRIPTION: Description of what this driver does - MACHINE_SETTINGS: Settings dict (optional) + MACHINE_SETTINGS: Driver specific settings dict (optional) """ SLUG: str @@ -116,6 +116,8 @@ class BaseMachineType(ClassValidationMixin, ClassProviderMixin): NAME: User friendly name for displaying DESCRIPTION: Description of what this machine type can do (default: "") + MACHINE_SETTINGS: Machine type specific settings dict (optional) + base_driver: Reference to the base driver for this machine type """ @@ -123,6 +125,8 @@ class BaseMachineType(ClassValidationMixin, ClassProviderMixin): NAME: str DESCRIPTION: str + MACHINE_SETTINGS: Dict[str, SettingsKeyType] + base_driver: BaseDriver # used by the ClassValidationMixin @@ -176,24 +180,28 @@ def initialize(self): self.errors.append(e) # --- helper functions - def get_setting(self, key, cache=False): + def get_setting(self, key, config_type: Literal["M", "D"], cache=False): """Return the 'value' of the setting associated with this machine. Arguments: key: The 'name' of the setting value to be retrieved + config_type: Either "M" (machine scoped settings) or "D" (driver scoped settings) cache: Whether to use RAM cached value (default = False) """ from machine.models import MachineSetting - return MachineSetting.get_setting(key, machine_config=self.machine_config, cache=cache) + config_type = MachineSetting.get_config_type(config_type) + return MachineSetting.get_setting(key, machine_config=self.machine_config, config_type=config_type, cache=cache) - def set_setting(self, key, value): + def set_setting(self, key, config_type: Literal["M", "D"], value): """Set plugin setting value by key. Arguments: key: The 'name' of the setting to set + config_type: Either "M" (machine scoped settings) or "D" (driver scoped settings) value: The 'value' of the setting """ from machine.models import MachineSetting - MachineSetting.set_setting(key, value, None, machine_config=self.machine_config) + config_type = MachineSetting.get_config_type(config_type) + MachineSetting.set_setting(key, value, None, machine_config=self.machine_config, config_type=config_type) diff --git a/InvenTree/machine/migrations/0001_initial.py b/InvenTree/machine/migrations/0001_initial.py index e48259df9c4..59dd844bfff 100644 --- a/InvenTree/machine/migrations/0001_initial.py +++ b/InvenTree/machine/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 3.2.19 on 2023-05-26 22:21 +# Generated by Django 3.2.19 on 2023-05-31 20:10 from django.db import migrations, models import django.db.models.deletion @@ -29,10 +29,11 @@ class Migration(migrations.Migration): ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('key', models.CharField(help_text='Settings key (must be unique - case insensitive)', max_length=50)), ('value', models.CharField(blank=True, help_text='Settings value', max_length=200)), + ('config_type', models.CharField(choices=[('M', 'Machine'), ('D', 'Driver')], max_length=1, verbose_name='Config type')), ('machine_config', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='settings', to='machine.machineconfig', verbose_name='Machine Config')), ], options={ - 'unique_together': {('machine_config', 'key')}, + 'unique_together': {('machine_config', 'config_type', 'key')}, }, ), ] diff --git a/InvenTree/machine/models.py b/InvenTree/machine/models.py index 7e4497ee444..4fbabf61b9a 100755 --- a/InvenTree/machine/models.py +++ b/InvenTree/machine/models.py @@ -1,4 +1,5 @@ import uuid +from typing import Literal from django.contrib import admin from django.db import models @@ -86,14 +87,18 @@ class MachineSetting(common.models.BaseInvenTreeSetting): """This models represents settings for individual machines.""" typ = "machine_config" - extra_unique_fields = ["machine_config"] + extra_unique_fields = ["machine_config", "config_type"] class Meta: """Meta for MachineSetting.""" unique_together = [ - ("machine_config", "key") + ("machine_config", "config_type", "key") ] + class ConfigType(models.TextChoices): + MACHINE = "M", _("Machine") + DRIVER = "D", _("Driver") + machine_config = models.ForeignKey( MachineConfig, related_name="settings", @@ -101,6 +106,19 @@ class Meta: on_delete=models.CASCADE ) + config_type = models.CharField( + verbose_name=_("Config type"), + max_length=1, + choices=ConfigType.choices, + ) + + @classmethod + def get_config_type(cls, config_type_str: Literal["M", "D"]): + if config_type_str == "M": + return cls.ConfigType.MACHINE + elif config_type_str == "D": + return cls.ConfigType.DRIVER + @classmethod def get_setting_definition(cls, key, **kwargs): """In the BaseInvenTreeSetting class, we have a class attribute named 'SETTINGS', which @@ -116,6 +134,10 @@ def get_setting_definition(cls, key, **kwargs): if 'settings' not in kwargs: machine_config: MachineConfig = kwargs.pop('machine_config', None) if machine_config: - kwargs['settings'] = getattr(machine_config.machine.driver, "MACHINE_SETTINGS", {}) + config_type = kwargs.get("config_type", None) + if config_type == cls.ConfigType.DRIVER: + kwargs['settings'] = getattr(machine_config.machine.driver, "MACHINE_SETTINGS", {}) + elif config_type == cls.ConfigType.MACHINE: + kwargs['settings'] = getattr(machine_config.machine, "MACHINE_SETTINGS", {}) return super().get_setting_definition(key, **kwargs) From 10a9372872e4ae22f044ed19eb881a7020984bca Mon Sep 17 00:00:00 2001 From: wolflu05 <76838159+wolflu05@users.noreply.github.com> Date: Wed, 31 May 2023 21:19:40 +0000 Subject: [PATCH 20/86] Refactor helper mixins into own file in InvenTree app --- InvenTree/InvenTree/helpers_mixin.py | 67 ++++++++++++++++++++++++++++ InvenTree/machine/machine_type.py | 64 +------------------------- 2 files changed, 68 insertions(+), 63 deletions(-) create mode 100644 InvenTree/InvenTree/helpers_mixin.py diff --git a/InvenTree/InvenTree/helpers_mixin.py b/InvenTree/InvenTree/helpers_mixin.py new file mode 100644 index 00000000000..7b13f10707e --- /dev/null +++ b/InvenTree/InvenTree/helpers_mixin.py @@ -0,0 +1,67 @@ +"""Provides helper mixins that are used throughout the InvenTree project.""" + +import inspect +from pathlib import Path + +from django.conf import settings + +from plugin import registry as plg_registry + + +class ClassValidationMixin: + """Mixin to validate class attributes and overrides. + + Class attributes: + required_attributes: List of class attributes that need to be defined + required_overrides: List of functions that need override + """ + + required_attributes = [] + required_overrides = [] + + @classmethod + def validate(cls): + def attribute_missing(key): + return not hasattr(cls, key) or getattr(cls, key) == "" + + def override_missing(base_implementation): + return base_implementation == getattr(cls, base_implementation.__name__, None) + + missing_attributes = list(filter(attribute_missing, cls.required_attributes)) + missing_overrides = list(filter(override_missing, cls.required_overrides)) + + errors = [] + + if len(missing_attributes) > 0: + errors.append(f"did not provide the following attributes: {', '.join(missing_attributes)}") + if len(missing_overrides) > 0: + errors.append(f"did not override the required attributes: {', '.join(map(lambda attr: attr.__name__, missing_overrides))}") + + if len(errors) > 0: + raise NotImplementedError(f"'{cls}' " + " and ".join(errors)) + + +class ClassProviderMixin: + """Mixin to get metadata about a class itself, e.g. the plugin that provided that class.""" + + @classmethod + def get_provider_file(cls): + """File that contains the Class definition.""" + return inspect.getfile(cls) + + @classmethod + def get_provider_plugin(cls): + """Plugin that contains the Class definition, otherwise None.""" + for plg in plg_registry.plugins.values(): + if plg.package_path == cls.__module__: + return plg + + @classmethod + def get_is_builtin(cls): + """Is this Class build in the Inventree source code?""" + try: + Path(cls.get_provider_file()).relative_to(settings.BASE_DIR) + return True + except ValueError: + # Path(...).relative_to throws an ValueError if its not relative to the InvenTree source base dir + return False diff --git a/InvenTree/machine/machine_type.py b/InvenTree/machine/machine_type.py index cd3b8e7ce31..06f81dea47d 100644 --- a/InvenTree/machine/machine_type.py +++ b/InvenTree/machine/machine_type.py @@ -1,10 +1,6 @@ -import inspect -from pathlib import Path from typing import TYPE_CHECKING, Dict, Literal -from django.conf import settings - -from plugin import registry as plg_registry +from InvenTree.helpers_mixin import ClassProviderMixin, ClassValidationMixin # Import only for typechecking, otherwise this throws cyclic import errors if TYPE_CHECKING: @@ -18,64 +14,6 @@ class SettingsKeyType: pass -# TODO: move to better destination (helpers) -class ClassValidationMixin: - """Mixin to validate class attributes and overrides. - - Class attributes: - required_attributes: List of class attributes that need to be defined - required_overrides: List of functions that need override - """ - - required_attributes = [] - required_overrides = [] - - @classmethod - def validate(cls): - def attribute_missing(key): - return not hasattr(cls, key) or getattr(cls, key) == "" - - def override_missing(base_implementation): - return base_implementation == getattr(cls, base_implementation.__name__, None) - - missing_attributes = list(filter(attribute_missing, cls.required_attributes)) - missing_overrides = list(filter(override_missing, cls.required_overrides)) - - errors = [] - - if len(missing_attributes) > 0: - errors.append(f"did not provide the following attributes: {', '.join(missing_attributes)}") - if len(missing_overrides) > 0: - errors.append(f"did not override the required attributes: {', '.join(map(lambda attr: attr.__name__, missing_overrides))}") - - if len(errors) > 0: - raise NotImplementedError(f"'{cls}' " + " and ".join(errors)) - - -class ClassProviderMixin: - @classmethod - def get_provider_file(cls): - """File that contains the Class definition.""" - return inspect.getfile(cls) - - @classmethod - def get_provider_plugin(cls): - """Plugin that contains the Class definition, otherwise None.""" - for plg in plg_registry.plugins.values(): - if plg.package_path == cls.__module__: - return plg - - @classmethod - def get_is_builtin(cls): - """Is this Class build in the Inventree source code?""" - try: - Path(cls.get_provider_file()).relative_to(settings.BASE_DIR) - return True - except ValueError: - # Path(...).relative_to throws an ValueError if its not relative to the InvenTree source base dir - return False - - class BaseDriver(ClassValidationMixin, ClassProviderMixin): """Base class for machine drivers From cd13b2a66ff94247bdbdbd71fa80de3d6330d70b Mon Sep 17 00:00:00 2001 From: wolflu05 <76838159+wolflu05@users.noreply.github.com> Date: Wed, 5 Jul 2023 20:34:21 +0000 Subject: [PATCH 21/86] Fixed typing and added required_attributes for BaseDriver --- InvenTree/InvenTree/helpers.py | 6 +++++- InvenTree/machine/machine_type.py | 14 ++++++++------ InvenTree/machine/models.py | 9 +++++---- InvenTree/machine/registry.py | 18 +++++++++--------- 4 files changed, 27 insertions(+), 20 deletions(-) diff --git a/InvenTree/InvenTree/helpers.py b/InvenTree/InvenTree/helpers.py index 45b1294b438..2aed37f4591 100644 --- a/InvenTree/InvenTree/helpers.py +++ b/InvenTree/InvenTree/helpers.py @@ -8,6 +8,7 @@ import os.path import re from decimal import Decimal, InvalidOperation +from typing import Set, Type, TypeVar from wsgiref.util import FileWrapper from django.conf import settings @@ -842,7 +843,10 @@ def get_target(self, obj): } -def inheritors(cls): +Inheritors_T = TypeVar("Inheritors_T") + + +def inheritors(cls: Type[Inheritors_T]) -> Set[Type[Inheritors_T]]: """Return all classes that are subclasses from the supplied cls.""" subcls = set() work = [cls] diff --git a/InvenTree/machine/machine_type.py b/InvenTree/machine/machine_type.py index 06f81dea47d..e54af75fa6b 100644 --- a/InvenTree/machine/machine_type.py +++ b/InvenTree/machine/machine_type.py @@ -1,4 +1,4 @@ -from typing import TYPE_CHECKING, Dict, Literal +from typing import TYPE_CHECKING, Dict, Literal, Type from InvenTree.helpers_mixin import ClassProviderMixin, ClassValidationMixin @@ -31,6 +31,8 @@ class BaseDriver(ClassValidationMixin, ClassProviderMixin): MACHINE_SETTINGS: Dict[str, SettingsKeyType] + required_attributes = ["SLUG", "NAME", "DESCRIPTION"] + def init_machine(self, machine: "BaseMachineType"): """This method get called for each active machine using that driver while initialization @@ -65,7 +67,7 @@ class BaseMachineType(ClassValidationMixin, ClassProviderMixin): MACHINE_SETTINGS: Dict[str, SettingsKeyType] - base_driver: BaseDriver + base_driver: Type[BaseDriver] # used by the ClassValidationMixin required_attributes = ["SLUG", "NAME", "DESCRIPTION", "base_driver"] @@ -118,7 +120,7 @@ def initialize(self): self.errors.append(e) # --- helper functions - def get_setting(self, key, config_type: Literal["M", "D"], cache=False): + def get_setting(self, key, config_type_str: Literal["M", "D"], cache=False): """Return the 'value' of the setting associated with this machine. Arguments: @@ -128,10 +130,10 @@ def get_setting(self, key, config_type: Literal["M", "D"], cache=False): """ from machine.models import MachineSetting - config_type = MachineSetting.get_config_type(config_type) + config_type = MachineSetting.get_config_type(config_type_str) return MachineSetting.get_setting(key, machine_config=self.machine_config, config_type=config_type, cache=cache) - def set_setting(self, key, config_type: Literal["M", "D"], value): + def set_setting(self, key, config_type_str: Literal["M", "D"], value): """Set plugin setting value by key. Arguments: @@ -141,5 +143,5 @@ def set_setting(self, key, config_type: Literal["M", "D"], value): """ from machine.models import MachineSetting - config_type = MachineSetting.get_config_type(config_type) + config_type = MachineSetting.get_config_type(config_type_str) MachineSetting.set_setting(key, value, None, machine_config=self.machine_config, config_type=config_type) diff --git a/InvenTree/machine/models.py b/InvenTree/machine/models.py index 4fbabf61b9a..04d0d2c1097 100755 --- a/InvenTree/machine/models.py +++ b/InvenTree/machine/models.py @@ -55,8 +55,9 @@ def save(self, *args, **kwargs) -> None: registry.add_machine(self, initialize=True) def delete(self, *args, **kwargs): - # remove machine first from registry - registry.remove_machine(self.machine) + # remove machine from registry first + if self.machine: + registry.remove_machine(self.machine) return super().delete(*args, **kwargs) @@ -71,7 +72,7 @@ def errors(self): @admin.display(boolean=True, description=_("Driver available")) def is_driver_available(self) -> bool: """Status if driver for machine is available""" - return self.machine and self.machine.driver is not None + return self.machine is not None and self.machine.driver is not None @admin.display(boolean=True, description=_("Machine has no errors")) def no_errors(self) -> bool: @@ -133,7 +134,7 @@ def get_setting_definition(cls, key, **kwargs): """ if 'settings' not in kwargs: machine_config: MachineConfig = kwargs.pop('machine_config', None) - if machine_config: + if machine_config and machine_config.machine: config_type = kwargs.get("config_type", None) if config_type == cls.ConfigType.DRIVER: kwargs['settings'] = getattr(machine_config.machine.driver, "MACHINE_SETTINGS", {}) diff --git a/InvenTree/machine/registry.py b/InvenTree/machine/registry.py index 779b1c314bb..fd9d68e7b60 100644 --- a/InvenTree/machine/registry.py +++ b/InvenTree/machine/registry.py @@ -1,5 +1,5 @@ import logging -from typing import Dict, List, Union +from typing import Dict, List, Set, Type, Union from uuid import UUID from machine.machine_type import BaseDriver, BaseMachineType @@ -14,12 +14,12 @@ def __init__(self) -> None: Set up all needed references for internal and external states. """ - self.machine_types: Dict[str, BaseMachineType] = {} - self.drivers: Dict[str, BaseDriver] = {} + self.machine_types: Dict[str, Type[BaseMachineType]] = {} + self.drivers: Dict[str, Type[BaseDriver]] = {} self.driver_instances: Dict[str, BaseDriver] = {} self.machines: Dict[str, BaseMachineType] = {} - self.base_drivers: List[BaseDriver] = [] + self.base_drivers: List[Type[BaseDriver]] = [] self.errors = [] def initialize(self): @@ -33,10 +33,10 @@ def discover_machine_types(self): logger.debug("Collecting machine types") - machine_types: Dict[str, BaseMachineType] = {} - base_drivers: List[BaseDriver] = [] + machine_types: Dict[str, Type[BaseMachineType]] = {} + base_drivers: List[Type[BaseDriver]] = [] - discovered_machine_types: List[BaseMachineType] = InvenTree.helpers.inheritors(BaseMachineType) + discovered_machine_types: Set[Type[BaseMachineType]] = InvenTree.helpers.inheritors(BaseMachineType) for machine_type in discovered_machine_types: try: machine_type.validate() @@ -61,9 +61,9 @@ def discover_drivers(self): logger.debug("Collecting machine drivers") - drivers: Dict[str, BaseDriver] = {} + drivers: Dict[str, Type[BaseDriver]] = {} - discovered_drivers: List[BaseDriver] = InvenTree.helpers.inheritors(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: From d4ff9f61856437c7b3919b8fa52916635cf74b35 Mon Sep 17 00:00:00 2001 From: wolflu05 <76838159+wolflu05@users.noreply.github.com> Date: Thu, 6 Jul 2023 15:36:46 +0000 Subject: [PATCH 22/86] fix: generic status import --- InvenTree/build/api.py | 2 +- InvenTree/generic/states/__init__.py | 2 -- InvenTree/order/api.py | 2 +- InvenTree/stock/api.py | 2 +- 4 files changed, 3 insertions(+), 5 deletions(-) diff --git a/InvenTree/build/api.py b/InvenTree/build/api.py index 63ae4321594..0feaf246311 100644 --- a/InvenTree/build/api.py +++ b/InvenTree/build/api.py @@ -11,7 +11,7 @@ from django_filters import rest_framework as rest_filters from InvenTree.api import AttachmentMixin, APIDownloadMixin, ListCreateDestroyAPIView, MetadataView -from generic.states import StatusView +from generic.states.api import StatusView from InvenTree.helpers import str2bool, isNull, DownloadFile from InvenTree.status_codes import BuildStatus, BuildStatusGroups from InvenTree.mixins import CreateAPI, RetrieveUpdateDestroyAPI, ListCreateAPI diff --git a/InvenTree/generic/states/__init__.py b/InvenTree/generic/states/__init__.py index 5137b7c3f46..952c31557ae 100644 --- a/InvenTree/generic/states/__init__.py +++ b/InvenTree/generic/states/__init__.py @@ -6,10 +6,8 @@ States can be extended with custom options for each InvenTree instance - those options are stored in the database and need to link back to state values. """ -from .api import StatusView from .states import StatusCode __all__ = [ - StatusView, StatusCode, ] diff --git a/InvenTree/order/api.py b/InvenTree/order/api.py index b4a60a95aec..99f04c5b2a1 100644 --- a/InvenTree/order/api.py +++ b/InvenTree/order/api.py @@ -16,7 +16,7 @@ import common.models as common_models from common.settings import settings from company.models import SupplierPart -from generic.states import StatusView +from generic.states.api import StatusView from InvenTree.api import (APIDownloadMixin, AttachmentMixin, ListCreateDestroyAPIView, MetadataView) from InvenTree.filters import SEARCH_ORDER_FILTER, SEARCH_ORDER_FILTER_ALIAS diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py index 3011e2e9df7..e1345113c0b 100644 --- a/InvenTree/stock/api.py +++ b/InvenTree/stock/api.py @@ -22,7 +22,7 @@ from build.serializers import BuildSerializer from company.models import Company, SupplierPart from company.serializers import CompanySerializer -from generic.states import StatusView +from generic.states.api import StatusView from InvenTree.api import (APIDownloadMixin, AttachmentMixin, ListCreateDestroyAPIView, MetadataView) from InvenTree.filters import (ORDER_FILTER, SEARCH_ORDER_FILTER, From 758550de56231e4f4a5d7de39bc53d65d8dd6483 Mon Sep 17 00:00:00 2001 From: wolflu05 <76838159+wolflu05@users.noreply.github.com> Date: Fri, 7 Jul 2023 11:11:30 +0000 Subject: [PATCH 23/86] Added first draft for machine states --- InvenTree/machine/__init__.py | 3 +- InvenTree/machine/admin.py | 4 +-- InvenTree/machine/machine_type.py | 32 +++++++++++++++++-- .../machine_types/LabelPrintingMachineType.py | 22 +++++++++---- InvenTree/machine/models.py | 16 ++++++++-- InvenTree/plugin/machine/__init__.py | 4 ++- 6 files changed, 65 insertions(+), 16 deletions(-) diff --git a/InvenTree/machine/__init__.py b/InvenTree/machine/__init__.py index 4268fc35da6..2487c28c2df 100755 --- a/InvenTree/machine/__init__.py +++ b/InvenTree/machine/__init__.py @@ -1,4 +1,4 @@ -from machine.machine_type import BaseDriver, BaseMachineType +from machine.machine_type import BaseDriver, BaseMachineType, MachineStatus from machine.registry import registry __all__ = [ @@ -6,4 +6,5 @@ "BaseMachineType", "BaseDriver", + "MachineStatus" ] diff --git a/InvenTree/machine/admin.py b/InvenTree/machine/admin.py index f7640837306..03a767af675 100755 --- a/InvenTree/machine/admin.py +++ b/InvenTree/machine/admin.py @@ -53,8 +53,8 @@ class MachineConfigAdmin(admin.ModelAdmin): form = MachineConfigAdminForm list_filter = ["active"] - list_display = ["name", "machine_type_key", "driver_key", "active", "is_driver_available", "no_errors"] - readonly_fields = ["is_driver_available", "get_admin_errors"] + list_display = ["name", "machine_type_key", "driver_key", "active", "is_driver_available", "no_errors", "get_machine_status"] + readonly_fields = ["is_driver_available", "get_admin_errors", "get_machine_status"] inlines = [MachineSettingInline] def get_readonly_fields(self, request, obj): diff --git a/InvenTree/machine/machine_type.py b/InvenTree/machine/machine_type.py index e54af75fa6b..630b414fc21 100644 --- a/InvenTree/machine/machine_type.py +++ b/InvenTree/machine/machine_type.py @@ -1,5 +1,6 @@ from typing import TYPE_CHECKING, Dict, Literal, Type +from generic.states import StatusCode from InvenTree.helpers_mixin import ClassProviderMixin, ClassValidationMixin # Import only for typechecking, otherwise this throws cyclic import errors @@ -14,6 +15,11 @@ class SettingsKeyType: pass +class MachineStatus(StatusCode): + """Base class for representing a set of machine status codes""" + pass + + class BaseDriver(ClassValidationMixin, ClassProviderMixin): """Base class for machine drivers @@ -56,21 +62,27 @@ class BaseMachineType(ClassValidationMixin, ClassProviderMixin): NAME: User friendly name for displaying DESCRIPTION: Description of what this machine type can do (default: "") + base_driver: Reference to the base driver for this machine type + MACHINE_SETTINGS: Machine type specific settings dict (optional) - base_driver: Reference to the base driver for this machine type + MACHINE_STATUS: Set of status codes this machine type can have + default_machine_status: Default machine status with which this machine gets initialized """ SLUG: str NAME: str DESCRIPTION: str + base_driver: Type[BaseDriver] + MACHINE_SETTINGS: Dict[str, SettingsKeyType] - base_driver: Type[BaseDriver] + MACHINE_STATUS: Type[MachineStatus] + default_machine_status: MachineStatus # used by the ClassValidationMixin - required_attributes = ["SLUG", "NAME", "DESCRIPTION", "base_driver"] + required_attributes = ["SLUG", "NAME", "DESCRIPTION", "base_driver", "MACHINE_STATUS", "default_machine_status"] def __init__(self, machine_config: MachineConfig) -> None: from machine import registry @@ -78,6 +90,9 @@ def __init__(self, machine_config: MachineConfig) -> None: self.errors = [] self.initialized = False + self.status = self.default_machine_status + self.status_text = "" + self.machine_config = machine_config self.driver = registry.get_driver_instance(self.machine_config.driver_key) @@ -145,3 +160,14 @@ def set_setting(self, key, config_type_str: Literal["M", "D"], value): config_type = MachineSetting.get_config_type(config_type_str) MachineSetting.set_setting(key, value, None, machine_config=self.machine_config, config_type=config_type) + + def set_status(self, status: MachineStatus): + """Set the machine status code. There are predefined ones for each MachineType. + + Import the MachineType to access it's `MACHINE_STATUS` enum. + """ + self.status = status + + def set_status_text(self, status_text: str): + """Set the machine status text. It can be any arbitrary text.""" + self.status_text = status_text diff --git a/InvenTree/machine/machine_types/LabelPrintingMachineType.py b/InvenTree/machine/machine_types/LabelPrintingMachineType.py index d8e2cce3392..7aea351bc6a 100644 --- a/InvenTree/machine/machine_types/LabelPrintingMachineType.py +++ b/InvenTree/machine/machine_types/LabelPrintingMachineType.py @@ -1,18 +1,18 @@ from django.utils.translation import gettext_lazy as _ -from machine.machine_type import BaseDriver, BaseMachineType +from machine.machine_type import BaseDriver, BaseMachineType, MachineStatus class BaseLabelPrintingDriver(BaseDriver): """Base label printing driver.""" - def print_label(): - """This function must be overriden.""" - raise NotImplementedError("The `print_label` function must be overriden!") + def print_label(self): + """This function must be overridden.""" + raise NotImplementedError("The `print_label` function must be overridden!") - def print_labels(): - """This function must be overriden.""" - raise NotImplementedError("The `print_labels` function must be overriden!") + def print_labels(self): + """This function must be overridden.""" + raise NotImplementedError("The `print_labels` function must be overridden!") requires_override = [print_label] @@ -23,3 +23,11 @@ class LabelPrintingMachineType(BaseMachineType): DESCRIPTION = _("Device used to print labels") base_driver = BaseLabelPrintingDriver + + class MACHINE_STATUS(MachineStatus): + CONNECTED = 10, _("Connected"), "success" + PAPER_MISSING = 20, _("Paper missing"), "warning" + PRINTING = 30, _("Printing"), "primary" + DISCONNECTED = 50, _("Disconnected"), "danger" + + default_machine_status = MACHINE_STATUS.DISCONNECTED diff --git a/InvenTree/machine/models.py b/InvenTree/machine/models.py index 04d0d2c1097..07e9e52923c 100755 --- a/InvenTree/machine/models.py +++ b/InvenTree/machine/models.py @@ -3,7 +3,7 @@ from django.contrib import admin from django.db import models -from django.utils.html import format_html_join +from django.utils.html import escape, format_html_join from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ @@ -74,7 +74,7 @@ def is_driver_available(self) -> bool: """Status if driver for machine is available""" return self.machine is not None and self.machine.driver is not None - @admin.display(boolean=True, description=_("Machine has no errors")) + @admin.display(boolean=True, description=_("No errors")) def no_errors(self) -> bool: """Status if machine has errors""" return len(self.errors) == 0 @@ -83,6 +83,18 @@ def no_errors(self) -> bool: def get_admin_errors(self): return format_html_join(mark_safe("
"), "{}", ((str(error),) for error in self.errors)) or mark_safe(f"{_('No errors')}") + @admin.display(description=_("Machine status")) + def get_machine_status(self): + if self.machine is None: + return None + + out = mark_safe(self.machine.status.render(self.machine.status)) + + if self.machine.status_text: + out += escape(f" ({self.machine.status_text})") + + return out + class MachineSetting(common.models.BaseInvenTreeSetting): """This models represents settings for individual machines.""" diff --git a/InvenTree/plugin/machine/__init__.py b/InvenTree/plugin/machine/__init__.py index 180648399fc..b6c92a2dff1 100644 --- a/InvenTree/plugin/machine/__init__.py +++ b/InvenTree/plugin/machine/__init__.py @@ -1,9 +1,11 @@ -from machine import BaseDriver, BaseMachineType, machine_types, registry +from machine import (BaseDriver, BaseMachineType, MachineStatus, machine_types, + registry) from machine.machine_types import * # noqa: F403, F401 __all__ = [ "registry", "BaseDriver", "BaseMachineType", + "MachineStatus", *machine_types.__all__ ] From 7d0a49573f0d81d232d59a2a43693008ac37b363 Mon Sep 17 00:00:00 2001 From: wolflu05 <76838159+wolflu05@users.noreply.github.com> Date: Sun, 9 Jul 2023 07:29:04 +0000 Subject: [PATCH 24/86] Added convention for status codes --- InvenTree/machine/machine_type.py | 19 ++++++++++++++++++- .../machine_types/LabelPrintingMachineType.py | 8 ++++---- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/InvenTree/machine/machine_type.py b/InvenTree/machine/machine_type.py index 630b414fc21..8eb57a8874f 100644 --- a/InvenTree/machine/machine_type.py +++ b/InvenTree/machine/machine_type.py @@ -16,7 +16,24 @@ class SettingsKeyType: class MachineStatus(StatusCode): - """Base class for representing a set of machine status codes""" + """Base class for representing a set of machine status codes. + + Use enum syntax to define the status codes, e.g. + ```python + CONNECTED = 200, _("Connected"), 'success' + ``` + + The values of the status can be accessed with `MachineStatus.CONNECTED.value`. + + Additionally there are helpers to access all additional attributes `text`, `label`, `color`. + + Status code ranges: + 1XX - Everything fine + 2XX - Warnings (e.g. ink is about to become empty) + 3XX - Something wrong with the machine (e.g. no labels are remaining on the spool) + 4XX - Something wrong with the driver (e.g. cannot connect to the machine) + 5XX - Unknown issues + """ pass diff --git a/InvenTree/machine/machine_types/LabelPrintingMachineType.py b/InvenTree/machine/machine_types/LabelPrintingMachineType.py index 7aea351bc6a..866902d293f 100644 --- a/InvenTree/machine/machine_types/LabelPrintingMachineType.py +++ b/InvenTree/machine/machine_types/LabelPrintingMachineType.py @@ -25,9 +25,9 @@ class LabelPrintingMachineType(BaseMachineType): base_driver = BaseLabelPrintingDriver class MACHINE_STATUS(MachineStatus): - CONNECTED = 10, _("Connected"), "success" - PAPER_MISSING = 20, _("Paper missing"), "warning" - PRINTING = 30, _("Printing"), "primary" - DISCONNECTED = 50, _("Disconnected"), "danger" + CONNECTED = 100, _("Connected"), "success" + PRINTING = 101, _("Printing"), "primary" + PAPER_MISSING = 301, _("Paper missing"), "warning" + DISCONNECTED = 400, _("Disconnected"), "danger" default_machine_status = MACHINE_STATUS.DISCONNECTED From 50d06955157580b09dcf58425242981b333a60bc Mon Sep 17 00:00:00 2001 From: wolflu05 <76838159+wolflu05@users.noreply.github.com> Date: Sun, 9 Jul 2023 10:55:44 +0000 Subject: [PATCH 25/86] Added update_machine hook --- InvenTree/machine/machine_type.py | 28 +++++++++++++++++++++------- InvenTree/machine/models.py | 16 +++++++++++++++- InvenTree/machine/registry.py | 4 ++++ 3 files changed, 40 insertions(+), 8 deletions(-) diff --git a/InvenTree/machine/machine_type.py b/InvenTree/machine/machine_type.py index 8eb57a8874f..1dbc7e3fb6a 100644 --- a/InvenTree/machine/machine_type.py +++ b/InvenTree/machine/machine_type.py @@ -1,4 +1,4 @@ -from typing import TYPE_CHECKING, Dict, Literal, Type +from typing import TYPE_CHECKING, Any, Dict, Literal, Type from generic.states import StatusCode from InvenTree.helpers_mixin import ClassProviderMixin, ClassValidationMixin @@ -57,13 +57,25 @@ class BaseDriver(ClassValidationMixin, ClassProviderMixin): required_attributes = ["SLUG", "NAME", "DESCRIPTION"] def init_machine(self, machine: "BaseMachineType"): - """This method get called for each active machine using that driver while initialization + """This method gets called for each active machine using that driver while initialization Arguments: machine: Machine instance """ pass + def update_machine(self, old_machine_state: Dict[str, Any], machine: "BaseMachineType"): + """This method gets called for each update of a machine + + TODO: this function gets called even the settings are not stored yet when edited through the admin dashboard + TODO: test also if API is done, that this function gets called for settings changes + + Arguments: + old_machine_state: Dict holding the old machine state before update + machine: Machine instance with the new state + """ + pass + def get_machines(self, **kwargs): """Return all machines using this driver. (By default only active machines)""" from machine import registry @@ -110,11 +122,11 @@ def __init__(self, machine_config: MachineConfig) -> None: self.status = self.default_machine_status self.status_text = "" - self.machine_config = machine_config - self.driver = registry.get_driver_instance(self.machine_config.driver_key) + self.pk = machine_config.pk + self.driver = registry.get_driver_instance(machine_config.driver_key) if not self.driver: - self.errors.append(f"Driver '{self.machine_config.driver_key}' not found") + self.errors.append(f"Driver '{machine_config.driver_key}' not found") if self.driver and not isinstance(self.driver, self.base_driver): self.errors.append(f"'{self.driver.NAME}' is incompatible with machine type '{self.NAME}'") @@ -128,8 +140,10 @@ def __str__(self): # --- properties @property - def pk(self): - return self.machine_config.pk + def machine_config(self): + # always fetch the machine_config if needed to ensure we get the newest reference + from .models import MachineConfig + return MachineConfig.objects.get(pk=self.pk) @property def name(self): diff --git a/InvenTree/machine/models.py b/InvenTree/machine/models.py index 07e9e52923c..3e95f038d61 100755 --- a/InvenTree/machine/models.py +++ b/InvenTree/machine/models.py @@ -48,11 +48,20 @@ def __str__(self) -> str: def save(self, *args, **kwargs) -> None: created = self._state.adding + old_machine = None + if self.pk and (old_machine := MachineConfig.objects.get(pk=self.pk)): + old_machine = old_machine.to_dict() + super().save(*args, **kwargs) - # machine was created, add it to the machine registry if created: + # machine was created, add it to the machine registry registry.add_machine(self, initialize=True) + elif old_machine: + # machine was updated, invoke update hook + # elif acts just as a type gate, old_machine should be defined always + # if machine is not created now which is already handled above + registry.update_machine(old_machine, self) def delete(self, *args, **kwargs): # remove machine from registry first @@ -61,6 +70,11 @@ def delete(self, *args, **kwargs): return super().delete(*args, **kwargs) + def to_dict(self): + machine = {f.name: f.value_to_string(self) for f in self._meta.fields} + machine['settings'] = {setting.key: (setting.value, setting.config_type) for setting in MachineSetting.objects.filter(machine_config=self)} + return machine + @property def machine(self): return registry.get_machine(self.pk) diff --git a/InvenTree/machine/registry.py b/InvenTree/machine/registry.py index fd9d68e7b60..bd275ec9c25 100644 --- a/InvenTree/machine/registry.py +++ b/InvenTree/machine/registry.py @@ -119,6 +119,10 @@ def add_machine(self, machine_config, initialize=True): if initialize and machine.active: machine.initialize() + def update_machine(self, old_machine_state, machine_config): + if (machine := machine_config.machine) and machine.driver: + machine.driver.update_machine(old_machine_state, machine) + def remove_machine(self, machine: BaseMachineType): self.machines.pop(str(machine.pk), None) From f4410859d87006f3c5028210e35ffede537dfc28 Mon Sep 17 00:00:00 2001 From: wolflu05 <76838159+wolflu05@users.noreply.github.com> Date: Sun, 9 Jul 2023 12:36:58 +0000 Subject: [PATCH 26/86] Removed unnecessary _key suffix from machine config model --- InvenTree/machine/admin.py | 8 ++++---- InvenTree/machine/machine_type.py | 4 ++-- InvenTree/machine/migrations/0001_initial.py | 4 ++-- InvenTree/machine/models.py | 4 ++-- InvenTree/machine/registry.py | 4 ++-- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/InvenTree/machine/admin.py b/InvenTree/machine/admin.py index 03a767af675..f3109dba170 100755 --- a/InvenTree/machine/admin.py +++ b/InvenTree/machine/admin.py @@ -18,8 +18,8 @@ def get_driver_choices(): # TODO: add conditional choices like shown here # Ref: https://www.reddit.com/r/django/comments/18cj55/conditional_choices_for_model_field_based_on/ # Ref: https://gist.github.com/blackrobot/4956070 - driver_key = forms.ChoiceField(label=_("Driver"), choices=get_driver_choices) - machine_type_key = forms.ChoiceField(label=_("Machine Type"), choices=get_machine_type_choices) + driver = forms.ChoiceField(label=_("Driver"), choices=get_driver_choices) + machine_type = forms.ChoiceField(label=_("Machine Type"), choices=get_machine_type_choices) class MachineSettingInline(admin.TabularInline): @@ -53,14 +53,14 @@ class MachineConfigAdmin(admin.ModelAdmin): form = MachineConfigAdminForm list_filter = ["active"] - list_display = ["name", "machine_type_key", "driver_key", "active", "is_driver_available", "no_errors", "get_machine_status"] + list_display = ["name", "machine_type", "driver", "active", "is_driver_available", "no_errors", "get_machine_status"] readonly_fields = ["is_driver_available", "get_admin_errors", "get_machine_status"] inlines = [MachineSettingInline] def get_readonly_fields(self, request, obj): # if update, don't allow changes on machine_type and driver if obj is not None: - return ["machine_type_key", "driver_key", *self.readonly_fields] + return ["machine_type", "driver", *self.readonly_fields] return self.readonly_fields diff --git a/InvenTree/machine/machine_type.py b/InvenTree/machine/machine_type.py index 1dbc7e3fb6a..efe933290a1 100644 --- a/InvenTree/machine/machine_type.py +++ b/InvenTree/machine/machine_type.py @@ -123,10 +123,10 @@ def __init__(self, machine_config: MachineConfig) -> None: self.status_text = "" self.pk = machine_config.pk - self.driver = registry.get_driver_instance(machine_config.driver_key) + self.driver = registry.get_driver_instance(machine_config.driver) if not self.driver: - self.errors.append(f"Driver '{machine_config.driver_key}' not found") + self.errors.append(f"Driver '{machine_config.driver}' not found") if self.driver and not isinstance(self.driver, self.base_driver): self.errors.append(f"'{self.driver.NAME}' is incompatible with machine type '{self.NAME}'") diff --git a/InvenTree/machine/migrations/0001_initial.py b/InvenTree/machine/migrations/0001_initial.py index 59dd844bfff..66a0a1e1c9c 100644 --- a/InvenTree/machine/migrations/0001_initial.py +++ b/InvenTree/machine/migrations/0001_initial.py @@ -18,8 +18,8 @@ class Migration(migrations.Migration): fields=[ ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), ('name', models.CharField(help_text='Name of machine', max_length=255, unique=True, verbose_name='Name')), - ('machine_type_key', models.CharField(help_text='Type of machine', max_length=255, verbose_name='Machine Type')), - ('driver_key', models.CharField(help_text='Driver used for the machine', max_length=255, verbose_name='Driver')), + ('machine_type', models.CharField(help_text='Type of machine', max_length=255, verbose_name='Machine Type')), + ('driver', models.CharField(help_text='Driver used for the machine', max_length=255, verbose_name='Driver')), ('active', models.BooleanField(default=True, help_text='Machines can be disabled', verbose_name='Active')), ], ), diff --git a/InvenTree/machine/models.py b/InvenTree/machine/models.py index 3e95f038d61..8cbada50986 100755 --- a/InvenTree/machine/models.py +++ b/InvenTree/machine/models.py @@ -23,13 +23,13 @@ class MachineConfig(models.Model): help_text=_("Name of machine") ) - machine_type_key = models.CharField( + machine_type = models.CharField( max_length=255, verbose_name=_("Machine Type"), help_text=_("Type of machine"), ) - driver_key = models.CharField( + driver = models.CharField( max_length=255, verbose_name=_("Driver"), help_text=_("Driver used for the machine") diff --git a/InvenTree/machine/registry.py b/InvenTree/machine/registry.py index bd275ec9c25..7921d7c0005 100644 --- a/InvenTree/machine/registry.py +++ b/InvenTree/machine/registry.py @@ -108,9 +108,9 @@ def load_machines(self): machine.initialize() def add_machine(self, machine_config, initialize=True): - machine_type = self.machine_types.get(machine_config.machine_type_key, None) + machine_type = self.machine_types.get(machine_config.machine_type, None) if machine_type is None: - self.errors.append(f"Machine type '{machine_config.machine_type_key}' not found") + self.errors.append(f"Machine type '{machine_config.machine_type}' not found") return machine: BaseMachineType = machine_type(machine_config) From 1db7825f8a062177a9a4bbef553cb6c0c29ddb41 Mon Sep 17 00:00:00 2001 From: wolflu05 <76838159+wolflu05@users.noreply.github.com> Date: Mon, 10 Jul 2023 21:10:55 +0000 Subject: [PATCH 27/86] Initil draft for machine API --- InvenTree/InvenTree/urls.py | 2 + InvenTree/common/models.py | 46 ++++ InvenTree/machine/api.py | 240 ++++++++++++++++++ InvenTree/machine/machine_type.py | 7 +- .../machine_types/LabelPrintingMachineType.py | 2 + InvenTree/machine/models.py | 6 +- InvenTree/machine/serializers.py | 160 ++++++++++++ 7 files changed, 459 insertions(+), 4 deletions(-) create mode 100644 InvenTree/machine/api.py create mode 100644 InvenTree/machine/serializers.py diff --git a/InvenTree/InvenTree/urls.py b/InvenTree/InvenTree/urls.py index 4098359bc71..0271afbeb1b 100644 --- a/InvenTree/InvenTree/urls.py +++ b/InvenTree/InvenTree/urls.py @@ -21,6 +21,7 @@ from company.urls import (company_urls, manufacturer_part_urls, supplier_part_urls) from label.api import label_api_urls +from machine.api import machine_api_urls from order.api import order_api_urls from order.urls import order_urls from part.api import bom_api_urls, part_api_urls @@ -59,6 +60,7 @@ re_path(r'^order/', include(order_api_urls)), re_path(r'^label/', include(label_api_urls)), re_path(r'^report/', include(report_api_urls)), + re_path(r'^machine/', include(machine_api_urls)), re_path(r'^user/', include(user_urls)), re_path(r'^admin/', include(admin_api_urls)), diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py index 1b546dd0e2d..471867edfe1 100644 --- a/InvenTree/common/models.py +++ b/InvenTree/common/models.py @@ -303,6 +303,52 @@ def allValues(cls, exclude_hidden=False, **kwargs): return settings + @classmethod + def all_items(cls, settings_definition: Dict[str, SettingsKeyType] | None, **kwargs): + """Return a list of "all" defined settings. + + This performs a single database lookup, + and then any settings which are not *in* the database + are assigned their default values + """ + filters = cls.get_filters(**kwargs) + + # Optionally filter by other keys + results = cls.objects.filter(**filters) + + # Query the database + settings: Dict[str, BaseInvenTreeSetting] = {} + + for setting in results: + if setting.key: + settings[setting.key.upper()] = setting + + # Specify any "default" values which are not in the database + settings_definition = settings_definition or cls.SETTINGS + for key, setting in settings_definition.items(): + if key.upper() not in settings: + setting_obj = cls( + key=key, + value=cls.get_setting_default(key, **filters), + **filters + ) + settings[key.upper()] = setting_obj + + for key, setting in settings.items(): + validator = cls.get_setting_validator(key) + + if cls.is_protected(key): + setting.value = '***' + elif cls.validator_is_bool(validator): + setting.value = InvenTree.helpers.str2bool(setting.value) + elif cls.validator_is_int(validator): + try: + setting.value = int(setting.value) + except ValueError: + setting.value = cls.get_setting_default(key, **filters) + + return list(settings.values()) + @classmethod def get_setting_definition(cls, key, **kwargs): """Return the 'definition' of a particular settings value, as a dict object. diff --git a/InvenTree/machine/api.py b/InvenTree/machine/api.py new file mode 100644 index 00000000000..216b466476e --- /dev/null +++ b/InvenTree/machine/api.py @@ -0,0 +1,240 @@ +from django.urls import include, path, re_path + +from drf_spectacular.utils import extend_schema +from rest_framework import permissions +from rest_framework.exceptions import NotFound +from rest_framework.response import Response +from rest_framework.views import APIView + +import machine.serializers as MachineSerializers +from generic.states.api import StatusView +from InvenTree.filters import SEARCH_ORDER_FILTER +from InvenTree.mixins import (ListCreateAPI, RetrieveUpdateAPI, + RetrieveUpdateDestroyAPI) +from machine import registry +from machine.models import MachineConfig, MachineSetting + + +class MachineList(ListCreateAPI): + """API endpoint for list of Machine objects. + + - GET: Return a list of all Machine objects + - POST: create a MachineConfig + """ + + queryset = MachineConfig.objects.all() + serializer_class = MachineSerializers.MachineConfigSerializer + + def get_serializer_class(self): + # allow driver, machine_type fields on creation + if self.request.method == "POST": + return MachineSerializers.MachineConfigCreateSerializer + return super().get_serializer_class() + + filter_backends = SEARCH_ORDER_FILTER + + filterset_fields = [ + "machine_type", + "driver", + "active", + ] + + ordering_fields = [ + "name", + "machine_type", + "driver", + "active", + ] + + ordering = [ + "-active", + "machine_type", + ] + + search_fields = [ + "name" + ] + + +class MachineDetail(RetrieveUpdateDestroyAPI): + """API detail endpoint for MachineConfig object. + + - GET: return a single MachineConfig + - PUT: update a MachineConfig + - PATCH: partial update a MachineConfig + - DELETE: delete a MachineConfig + """ + + queryset = MachineConfig.objects.all() + serializer_class = MachineSerializers.MachineConfigSerializer + + def update(self, request, *args, **kwargs): + return super().update(request, *args, **kwargs) + + +def get_machine(machine_pk): + """Get machine by pk. + + Raises: + NotFound: If machine is not found + + Returns: + BaseMachineType: The machine instance in the registry + """ + machine = registry.get_machine(machine_pk) + + if machine is None: + raise NotFound(detail=f"Machine '{machine_pk}' not found") + + return machine + + +class MachineSettingList(APIView): + """List endpoint for all machine related settings. + + - GET: return all settings for a machine config + """ + + permission_classes = [permissions.IsAuthenticated] + + @extend_schema(responses={200: MachineSerializers.MachineSettingSerializer(many=True)}) + def get(self, request, pk): + machine = get_machine(pk) + + setting_types = [ + (machine.machine_settings, MachineSetting.ConfigType.MACHINE), + (machine.driver_settings, MachineSetting.ConfigType.DRIVER), + ] + + all_settings = [] + + for settings, config_type in setting_types: + all_settings.extend(MachineSetting.all_items(settings, machine_config=machine.machine_config, config_type=config_type)) + + results = MachineSerializers.MachineSettingSerializer(all_settings, many=True).data + return Response(results) + + +class MachineSettingDetail(RetrieveUpdateAPI): + """Detail endpoint for a machine-specific setting. + + - GET: Get machine setting detail + - PUT: Update machine setting + - PATCH: Update machine setting + + (Note that these cannot be created or deleted via API) + """ + + queryset = MachineSetting.objects.all() + serializer_class = MachineSerializers.MachineSettingSerializer + + def get_object(self): + """Lookup machine setting object, based on the URL.""" + pk = self.kwargs["pk"] + key = self.kwargs["key"] + config_type = MachineSetting.get_config_type(self.kwargs["config_type"]) + + machine = get_machine(pk) + + setting_map = {MachineSetting.ConfigType.MACHINE: machine.machine_settings, + MachineSetting.ConfigType.DRIVER: machine.driver_settings} + if key.upper() not in setting_map[config_type]: + raise NotFound(detail=f"Machine '{machine.name}' has no {config_type.name} setting matching '{key.upper()}'") + + return MachineSetting.get_setting_object(key, machine_config=machine.machine_config, config_type=config_type) + + +class MachineTypesList(APIView): + """List API Endpoint for all discovered machine types. + + - GET: List all machine types + """ + + permission_classes = [permissions.IsAuthenticated] + + @extend_schema(responses={200: MachineSerializers.MachineTypeSerializer(many=True)}) + def get(self, request): + machine_types = list(registry.machine_types.values()) + results = MachineSerializers.MachineTypeSerializer(machine_types, many=True).data + return Response(results) + + +class MachineTypeStatusView(StatusView): + """List all status codes for a machine type. + + - GET: List all status codes for this machine type + """ + + def get_status_model(self, *args, **kwargs): + # dynamically inject the StatusCode model from the machine type url param + machine_type = registry.machine_types.get(self.kwargs["machine_type"], None) + if machine_type is None: + raise NotFound(detail=f"Machine type '{self.kwargs['machine_type']}' not found") + self.kwargs[self.MODEL_REF] = machine_type.MACHINE_STATUS + return super().get_status_model(*args, **kwargs) + + +class MachineDriverList(APIView): + """List API Endpoint for all discovered machine drivers. + + - GET: List all machine drivers + """ + + permission_classes = [permissions.IsAuthenticated] + + @extend_schema(responses={200: MachineSerializers.MachineDriverSerializer(many=True)}) + def get(self, request): + drivers = registry.drivers.values() + if machine_type := request.query_params.get("machine_type", None): + drivers = filter(lambda d: d.machine_type == machine_type, drivers) + + results = MachineSerializers.MachineDriverSerializer(list(drivers), many=True).data + return Response(results) + + +class RegistryStatusView(APIView): + """Status API endpoint for the machine registry. + + - GET: Provide status data for the machine registry + """ + + permission_classes = [permissions.IsAuthenticated] + + serializer_class = MachineSerializers.MachineRegistryStatusSerializer + + def get(self, request): + result = MachineSerializers.MachineRegistryStatusSerializer({ + "registry_errors": list(map(str, registry.errors)) + }).data + + return Response(result) + + +machine_api_urls = [ + # machine types + re_path(r"^types/", include([ + path(r"/", include([ + re_path(r"^status/", MachineTypeStatusView.as_view(), name="api-machine-type-status"), + ])), + re_path(r"^.*$", MachineTypesList.as_view(), name="api-machine-types"), + ])), + + # machine drivers + re_path(r"^drivers/", MachineDriverList.as_view(), name="api-machine-drivers"), + + # registry status + re_path(r"^status/", RegistryStatusView.as_view(), name="api-machine-registry-status"), + + # detail views for a single Machine + path(r"/", include([ + re_path(r"^settings/", include([ + re_path(r"^(?PM|D)/(?P\w+)/", MachineSettingDetail.as_view(), name="api-machine-settings-detail"), + re_path(r"^.*$", MachineSettingList.as_view(), name="api-machine-settings"), + ])), + + re_path(r"^.*$", MachineDetail.as_view(), name="api-machine-detail"), + ])), + + # machine list and create + re_path(r"^.*$", MachineList.as_view(), name="api-machine-list"), +] diff --git a/InvenTree/machine/machine_type.py b/InvenTree/machine/machine_type.py index efe933290a1..a68b6eefa5a 100644 --- a/InvenTree/machine/machine_type.py +++ b/InvenTree/machine/machine_type.py @@ -54,7 +54,9 @@ class BaseDriver(ClassValidationMixin, ClassProviderMixin): MACHINE_SETTINGS: Dict[str, SettingsKeyType] - required_attributes = ["SLUG", "NAME", "DESCRIPTION"] + machine_type: str + + required_attributes = ["SLUG", "NAME", "DESCRIPTION", "machine_type"] def init_machine(self, machine: "BaseMachineType"): """This method gets called for each active machine using that driver while initialization @@ -130,6 +132,9 @@ def __init__(self, machine_config: MachineConfig) -> None: if self.driver and not isinstance(self.driver, self.base_driver): self.errors.append(f"'{self.driver.NAME}' is incompatible with machine type '{self.NAME}'") + self.machine_settings: Dict[str, SettingsKeyType] = getattr(self, "MACHINE_SETTINGS", {}) + self.driver_settings: Dict[str, SettingsKeyType] = getattr(self.driver, "MACHINE_SETTINGS", {}) + if len(self.errors) > 0: return diff --git a/InvenTree/machine/machine_types/LabelPrintingMachineType.py b/InvenTree/machine/machine_types/LabelPrintingMachineType.py index 866902d293f..efa756bb7e8 100644 --- a/InvenTree/machine/machine_types/LabelPrintingMachineType.py +++ b/InvenTree/machine/machine_types/LabelPrintingMachineType.py @@ -6,6 +6,8 @@ class BaseLabelPrintingDriver(BaseDriver): """Base label printing driver.""" + machine_type = "label_printer" + def print_label(self): """This function must be overridden.""" raise NotImplementedError("The `print_label` function must be overridden!") diff --git a/InvenTree/machine/models.py b/InvenTree/machine/models.py index 8cbada50986..02389de8923 100755 --- a/InvenTree/machine/models.py +++ b/InvenTree/machine/models.py @@ -49,7 +49,7 @@ def save(self, *args, **kwargs) -> None: created = self._state.adding old_machine = None - if self.pk and (old_machine := MachineConfig.objects.get(pk=self.pk)): + if not created and self.pk and (old_machine := MachineConfig.objects.get(pk=self.pk)): old_machine = old_machine.to_dict() super().save(*args, **kwargs) @@ -163,8 +163,8 @@ def get_setting_definition(cls, key, **kwargs): if machine_config and machine_config.machine: config_type = kwargs.get("config_type", None) if config_type == cls.ConfigType.DRIVER: - kwargs['settings'] = getattr(machine_config.machine.driver, "MACHINE_SETTINGS", {}) + kwargs['settings'] = machine_config.machine.driver_settings elif config_type == cls.ConfigType.MACHINE: - kwargs['settings'] = getattr(machine_config.machine, "MACHINE_SETTINGS", {}) + kwargs['settings'] = machine_config.machine.machine_settings return super().get_setting_definition(key, **kwargs) diff --git a/InvenTree/machine/serializers.py b/InvenTree/machine/serializers.py new file mode 100644 index 00000000000..161a884328b --- /dev/null +++ b/InvenTree/machine/serializers.py @@ -0,0 +1,160 @@ +from typing import List + +from rest_framework import serializers + +from common.serializers import GenericReferencedSettingSerializer +from InvenTree.helpers_mixin import ClassProviderMixin +from machine.models import MachineConfig, MachineSetting + + +class MachineConfigSerializer(serializers.ModelSerializer): + """Serializer for a MachineConfig.""" + + class Meta: + """Meta for serializer.""" + model = MachineConfig + fields = [ + "pk", + "name", + "machine_type", + "driver", + "initialized", + "active", + "status", + "status_text", + "machine_errors", + "is_driver_available", + ] + + read_only_fields = [ + "machine_type", + "driver", + ] + + initialized = serializers.SerializerMethodField("get_initialized") + status = serializers.SerializerMethodField("get_status") + status_text = serializers.SerializerMethodField("get_status_text") + machine_errors = serializers.SerializerMethodField("get_errors") + is_driver_available = serializers.SerializerMethodField("get_is_driver_available") + + def get_initialized(self, obj: MachineConfig) -> bool: + if obj.machine: + return obj.machine.initialized + return False + + def get_status(self, obj: MachineConfig) -> int: + if obj.machine: + return obj.machine.status + return -1 + + def get_status_text(self, obj: MachineConfig) -> str: + if obj.machine: + return obj.machine.status_text + return "" + + def get_errors(self, obj: MachineConfig) -> List[str]: + return obj.errors + + def get_is_driver_available(self, obj: MachineConfig) -> bool: + return obj.is_driver_available() + + +class MachineConfigCreateSerializer(MachineConfigSerializer): + """Serializer for creating a MachineConfig.""" + + class Meta(MachineConfigSerializer.Meta): + """Meta for serializer.""" + read_only_fields = [] + + +class MachineSettingSerializer(GenericReferencedSettingSerializer): + """Serializer for the MachineSetting model.""" + + MODEL = MachineSetting + EXTRA_FIELDS = [ + "machine", + "config_type", + ] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # remove unwanted fields + unwanted_fields = ["pk", "model_name", "api_url", "typ"] + for f in unwanted_fields: + if f in self.Meta.fields: + self.Meta.fields.remove(f) + + setattr(self.Meta, "read_only_fields", ["config_type"]) + + machine = serializers.SlugField(source="machine_config.pk", read_only=True) + + +class BaseMachineClassSerializer(serializers.Serializer): + """Serializer for a BaseClass.""" + + class Meta: + """Meta for a serializer.""" + fields = [ + "slug", + "name", + "description", + "provider_file", + "provider_plugin", + "is_builtin", + ] + + read_only_fields = fields + + slug = serializers.SlugField(source="SLUG") + name = serializers.CharField(source="NAME") + description = serializers.CharField(source="DESCRIPTION") + provider_file = serializers.SerializerMethodField("get_provider_file") + provider_plugin = serializers.SerializerMethodField("get_provider_plugin") + is_builtin = serializers.SerializerMethodField("get_is_builtin") + + def get_provider_file(self, obj: ClassProviderMixin) -> str: + return obj.get_provider_file() + + def get_provider_plugin(self, obj: ClassProviderMixin) -> str | None: + plugin = obj.get_provider_plugin() + if plugin: + return plugin.slug + return None + + def get_is_builtin(self, obj: ClassProviderMixin) -> bool: + return obj.get_is_builtin() + + +class MachineTypeSerializer(BaseMachineClassSerializer): + """Serializer for a BaseMachineType class.""" + + class Meta(BaseMachineClassSerializer.Meta): + """Meta for a serializer.""" + fields = [ + *BaseMachineClassSerializer.Meta.fields + ] + + +class MachineDriverSerializer(BaseMachineClassSerializer): + """Serializer for a BaseMachineDriver class.""" + + class Meta(BaseMachineClassSerializer.Meta): + """Meta for a serializer.""" + fields = [ + *BaseMachineClassSerializer.Meta.fields, + "machine_type", + ] + + machine_type = serializers.SlugField(read_only=True) + + +class MachineRegistryStatusSerializer(serializers.Serializer): + """Serializer for machine registry status.""" + + class Meta: + fields = [ + "registry_errors", + ] + + registry_errors = serializers.ListField(child=serializers.CharField()) From 71149da047c9088ef30d4316d3989b5f1718e999 Mon Sep 17 00:00:00 2001 From: wolflu05 <76838159+wolflu05@users.noreply.github.com> Date: Tue, 11 Jul 2023 07:31:45 +0000 Subject: [PATCH 28/86] Refactored BaseInvenTreeSetting all_items and allValues method --- InvenTree/common/models.py | 89 +++++++++++--------------------- InvenTree/machine/api.py | 3 +- InvenTree/machine/serializers.py | 3 -- 3 files changed, 33 insertions(+), 62 deletions(-) diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py index 471867edfe1..8114bf80652 100644 --- a/InvenTree/common/models.py +++ b/InvenTree/common/models.py @@ -250,13 +250,15 @@ def get_filters_for_instance(self): return {key: getattr(self, key, None) for key in self.extra_unique_fields if hasattr(self, key)} @classmethod - def allValues(cls, exclude_hidden=False, **kwargs): - """Return a dict of "all" defined global settings. + def all_settings(cls, *, exclude_hidden=False, settings_definition: Dict[str, SettingsKeyType] | None = None, **kwargs): + """Return a list of "all" defined settings. This performs a single database lookup, and then any settings which are not *in* the database are assigned their default values """ + filters = cls.get_filters(**kwargs) + results = cls.objects.all() if exclude_hidden: @@ -264,61 +266,11 @@ def allValues(cls, exclude_hidden=False, **kwargs): results = results.exclude(key__startswith='_') # Optionally filter by other keys - results = results.filter(**cls.get_filters(**kwargs)) - - # Query the database - settings = {} - - for setting in results: - if setting.key: - settings[setting.key.upper()] = setting.value - - # Specify any "default" values which are not in the database - for key in cls.SETTINGS.keys(): - - if key.upper() not in settings: - settings[key.upper()] = cls.get_setting_default(key) - - if exclude_hidden: - hidden = cls.SETTINGS[key].get('hidden', False) - - if hidden: - # Remove hidden items - del settings[key.upper()] - - for key, value in settings.items(): - validator = cls.get_setting_validator(key) - - if cls.is_protected(key): - value = '***' - elif cls.validator_is_bool(validator): - value = InvenTree.helpers.str2bool(value) - elif cls.validator_is_int(validator): - try: - value = int(value) - except ValueError: - value = cls.get_setting_default(key) - - settings[key] = value - - return settings - - @classmethod - def all_items(cls, settings_definition: Dict[str, SettingsKeyType] | None, **kwargs): - """Return a list of "all" defined settings. - - This performs a single database lookup, - and then any settings which are not *in* the database - are assigned their default values - """ - filters = cls.get_filters(**kwargs) - - # Optionally filter by other keys - results = cls.objects.filter(**filters) + results = results.filter(**filters) - # Query the database settings: Dict[str, BaseInvenTreeSetting] = {} + # Query the database for setting in results: if setting.key: settings[setting.key.upper()] = setting @@ -327,13 +279,17 @@ def all_items(cls, settings_definition: Dict[str, SettingsKeyType] | None, **kwa settings_definition = settings_definition or cls.SETTINGS for key, setting in settings_definition.items(): if key.upper() not in settings: - setting_obj = cls( - key=key, + settings[key.upper()] = cls( + key=key.upper(), value=cls.get_setting_default(key, **filters), **filters ) - settings[key.upper()] = setting_obj + # remove any hidden settings + if exclude_hidden and setting.get("hidden", False): + del settings[key.upper()] + + # format settings values and remove protected for key, setting in settings.items(): validator = cls.get_setting_validator(key) @@ -347,7 +303,24 @@ def all_items(cls, settings_definition: Dict[str, SettingsKeyType] | None, **kwa except ValueError: setting.value = cls.get_setting_default(key, **filters) - return list(settings.values()) + return settings + + @classmethod + def allValues(cls, *, exclude_hidden=False, settings_definition: Dict[str, SettingsKeyType] | None = None, **kwargs): + """Return a dict of "all" defined global settings. + + This performs a single database lookup, + and then any settings which are not *in* the database + are assigned their default values + """ + all_settings = cls.all_settings(exclude_hidden=exclude_hidden, settings_definition=settings_definition, **kwargs) + + settings: Dict[str, Any] = {} + + for key, setting in all_settings.items(): + settings[key] = setting.value + + return settings @classmethod def get_setting_definition(cls, key, **kwargs): diff --git a/InvenTree/machine/api.py b/InvenTree/machine/api.py index 216b466476e..0b03eaa5bf4 100644 --- a/InvenTree/machine/api.py +++ b/InvenTree/machine/api.py @@ -109,7 +109,8 @@ def get(self, request, pk): all_settings = [] for settings, config_type in setting_types: - all_settings.extend(MachineSetting.all_items(settings, machine_config=machine.machine_config, config_type=config_type)) + settings_dict = MachineSetting.all_settings(settings_definition=settings, machine_config=machine.machine_config, config_type=config_type) + all_settings.extend(list(settings_dict.values())) results = MachineSerializers.MachineSettingSerializer(all_settings, many=True).data return Response(results) diff --git a/InvenTree/machine/serializers.py b/InvenTree/machine/serializers.py index 161a884328b..e82d610b117 100644 --- a/InvenTree/machine/serializers.py +++ b/InvenTree/machine/serializers.py @@ -72,7 +72,6 @@ class MachineSettingSerializer(GenericReferencedSettingSerializer): MODEL = MachineSetting EXTRA_FIELDS = [ - "machine", "config_type", ] @@ -87,8 +86,6 @@ def __init__(self, *args, **kwargs): setattr(self.Meta, "read_only_fields", ["config_type"]) - machine = serializers.SlugField(source="machine_config.pk", read_only=True) - class BaseMachineClassSerializer(serializers.Serializer): """Serializer for a BaseClass.""" From d94a6ea82a4ab037b380f85c4010470882390de1 Mon Sep 17 00:00:00 2001 From: wolflu05 <76838159+wolflu05@users.noreply.github.com> Date: Tue, 11 Jul 2023 08:17:56 +0000 Subject: [PATCH 29/86] Added required to InvenTreeBaseSetting and check_settings method --- InvenTree/common/models.py | 32 ++++++++++++++++++++++++++++++- InvenTree/machine/api.py | 10 ++-------- InvenTree/machine/machine_type.py | 24 ++++++++++++++++++++++- 3 files changed, 56 insertions(+), 10 deletions(-) diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py index 8114bf80652..54de069e275 100644 --- a/InvenTree/common/models.py +++ b/InvenTree/common/models.py @@ -127,6 +127,7 @@ class SettingsKeyType(TypedDict, total=False): before_save: Function that gets called after save with *args, **kwargs (optional) after_save: Function that gets called after save with *args, **kwargs (optional) protected: Protected values are not returned to the client, instead "***" is returned (optional, default: False) + required: Is this setting required to work, can be used in combination with .check_all_settings(...) (optional, default: False) model: Auto create a dropdown menu to select an associated model instance (e.g. 'company.company', 'auth.user' and 'auth.group' are possible too, optional) """ @@ -140,6 +141,7 @@ class SettingsKeyType(TypedDict, total=False): before_save: Callable[..., None] after_save: Callable[..., None] protected: bool + required: bool model: str @@ -322,6 +324,22 @@ def allValues(cls, *, exclude_hidden=False, settings_definition: Dict[str, Setti return settings + @classmethod + def check_all_settings(cls, *, exclude_hidden=False, settings_definition: Dict[str, SettingsKeyType] | None = None, **kwargs): + """Check if all required settings are set by definition.""" + all_settings = cls.all_settings(exclude_hidden=exclude_hidden, settings_definition=settings_definition, **kwargs) + + missing_settings: List[str] = [] + + for setting in all_settings.values(): + if setting.required: + value = setting.value or cls.get_setting_default(setting.key) + + if value == "": + missing_settings.append(setting.key.upper()) + + return len(missing_settings) == 0, missing_settings + @classmethod def get_setting_definition(cls, key, **kwargs): """Return the 'definition' of a particular settings value, as a dict object. @@ -848,7 +866,7 @@ def as_int(self): @classmethod def is_protected(cls, key, **kwargs): """Check if the setting value is protected.""" - setting = cls.get_setting_definition(key, **kwargs) + setting = cls.get_setting_definition(key, **cls.get_filters(**kwargs)) return setting.get('protected', False) @@ -857,6 +875,18 @@ def protected(self): """Returns if setting is protected from rendering.""" return self.__class__.is_protected(self.key, **self.get_filters_for_instance()) + @classmethod + def is_required(cls, key, **kwargs): + """Check if this setting value is required.""" + setting = cls.get_setting_definition(key, **cls.get_filters(**kwargs)) + + return setting.get("required", False) + + @property + def required(self): + """Returns if setting is required.""" + return self.__class__.is_required(self.key, **self.get_filters_for_instance()) + def settings_group_options(): """Build up group tuple for settings based on your choices.""" diff --git a/InvenTree/machine/api.py b/InvenTree/machine/api.py index 0b03eaa5bf4..c1e27983a2d 100644 --- a/InvenTree/machine/api.py +++ b/InvenTree/machine/api.py @@ -101,14 +101,9 @@ class MachineSettingList(APIView): def get(self, request, pk): machine = get_machine(pk) - setting_types = [ - (machine.machine_settings, MachineSetting.ConfigType.MACHINE), - (machine.driver_settings, MachineSetting.ConfigType.DRIVER), - ] - all_settings = [] - for settings, config_type in setting_types: + for settings, config_type in machine.setting_types: settings_dict = MachineSetting.all_settings(settings_definition=settings, machine_config=machine.machine_config, config_type=config_type) all_settings.extend(list(settings_dict.values())) @@ -137,8 +132,7 @@ def get_object(self): machine = get_machine(pk) - setting_map = {MachineSetting.ConfigType.MACHINE: machine.machine_settings, - MachineSetting.ConfigType.DRIVER: machine.driver_settings} + setting_map = dict((d, s) for s, d in machine.setting_types) if key.upper() not in setting_map[config_type]: raise NotFound(detail=f"Machine '{machine.name}' has no {config_type.name} setting matching '{key.upper()}'") diff --git a/InvenTree/machine/machine_type.py b/InvenTree/machine/machine_type.py index a68b6eefa5a..ce05a8ecf35 100644 --- a/InvenTree/machine/machine_type.py +++ b/InvenTree/machine/machine_type.py @@ -1,4 +1,4 @@ -from typing import TYPE_CHECKING, Any, Dict, Literal, Type +from typing import TYPE_CHECKING, Any, Dict, List, Literal, Tuple, Type from generic.states import StatusCode from InvenTree.helpers_mixin import ClassProviderMixin, ClassValidationMixin @@ -117,6 +117,7 @@ class BaseMachineType(ClassValidationMixin, ClassProviderMixin): def __init__(self, machine_config: MachineConfig) -> None: from machine import registry + from machine.models import MachineSetting self.errors = [] self.initialized = False @@ -135,6 +136,11 @@ def __init__(self, machine_config: MachineConfig) -> None: self.machine_settings: Dict[str, SettingsKeyType] = getattr(self, "MACHINE_SETTINGS", {}) self.driver_settings: Dict[str, SettingsKeyType] = getattr(self.driver, "MACHINE_SETTINGS", {}) + self.setting_types: List[Tuple[Dict[str, SettingsKeyType], MachineSetting.ConfigType]] = [ + (self.machine_settings, MachineSetting.ConfigType.MACHINE), + (self.driver_settings, MachineSetting.ConfigType.DRIVER), + ] + if len(self.errors) > 0: return @@ -197,6 +203,22 @@ def set_setting(self, key, config_type_str: Literal["M", "D"], value): config_type = MachineSetting.get_config_type(config_type_str) MachineSetting.set_setting(key, value, None, machine_config=self.machine_config, config_type=config_type) + def check_settings(self): + """Check if all required settings for this machine are defined. + + Returns: + is_valid: Are all required settings defined + missing_settings: List of all settings that are missing (empty if is_valid is 'True') + """ + from machine.models import MachineSetting + + missing_settings: List[str] = [] + for settings, config_type in self.setting_types: + is_valid, missing = MachineSetting.check_all_settings(settings_definition=settings, machine_config=self.machine_config, config_type=config_type) + missing_settings.extend(missing) + + return len(missing_settings) == 0, missing_settings + def set_status(self, status: MachineStatus): """Set the machine status code. There are predefined ones for each MachineType. From fa34c8c93607d4955244b3e9ecb2fa48e10b3cb6 Mon Sep 17 00:00:00 2001 From: wolflu05 <76838159+wolflu05@users.noreply.github.com> Date: Tue, 11 Jul 2023 17:47:50 +0000 Subject: [PATCH 30/86] check if all required machine settings are defined and refactor: use getattr --- InvenTree/machine/admin.py | 4 ++-- InvenTree/machine/machine_type.py | 18 ++++++++++++++---- InvenTree/machine/models.py | 7 ++++++- InvenTree/machine/registry.py | 9 +++++---- InvenTree/machine/serializers.py | 14 ++++---------- 5 files changed, 31 insertions(+), 21 deletions(-) diff --git a/InvenTree/machine/admin.py b/InvenTree/machine/admin.py index f3109dba170..c23147348c3 100755 --- a/InvenTree/machine/admin.py +++ b/InvenTree/machine/admin.py @@ -53,8 +53,8 @@ class MachineConfigAdmin(admin.ModelAdmin): form = MachineConfigAdminForm list_filter = ["active"] - list_display = ["name", "machine_type", "driver", "active", "is_driver_available", "no_errors", "get_machine_status"] - readonly_fields = ["is_driver_available", "get_admin_errors", "get_machine_status"] + list_display = ["name", "machine_type", "driver", "initialized", "active", "no_errors", "get_machine_status"] + readonly_fields = ["initialized", "is_driver_available", "get_admin_errors", "get_machine_status"] inlines = [MachineSettingInline] def get_readonly_fields(self, request, obj): diff --git a/InvenTree/machine/machine_type.py b/InvenTree/machine/machine_type.py index ce05a8ecf35..cccb78ff192 100644 --- a/InvenTree/machine/machine_type.py +++ b/InvenTree/machine/machine_type.py @@ -170,6 +170,16 @@ def initialize(self): if self.driver is None: return + # check if all required settings are defined before continue with init process + settings_valid, missing_settings = self.check_settings() + if not settings_valid: + error_parts = [] + for config_type, missing in missing_settings.items(): + if len(missing) > 0: + error_parts.append(f"{config_type.name} settings: " + ", ".join(missing)) + self.errors.append(f"Missing {' and '.join(error_parts)}") + return + try: self.driver.init_machine(self) self.initialized = True @@ -208,16 +218,16 @@ def check_settings(self): Returns: is_valid: Are all required settings defined - missing_settings: List of all settings that are missing (empty if is_valid is 'True') + missing_settings: Dict[ConfigType, List[str]] of all settings that are missing (empty if is_valid is 'True') """ from machine.models import MachineSetting - missing_settings: List[str] = [] + missing_settings: Dict[MachineSetting.ConfigType, List[str]] = {} for settings, config_type in self.setting_types: is_valid, missing = MachineSetting.check_all_settings(settings_definition=settings, machine_config=self.machine_config, config_type=config_type) - missing_settings.extend(missing) + missing_settings[config_type] = missing - return len(missing_settings) == 0, missing_settings + return all(len(missing) == 0 for missing in missing_settings), missing_settings def set_status(self, status: MachineStatus): """Set the machine status code. There are predefined ones for each MachineType. diff --git a/InvenTree/machine/models.py b/InvenTree/machine/models.py index 02389de8923..a0293581411 100755 --- a/InvenTree/machine/models.py +++ b/InvenTree/machine/models.py @@ -81,7 +81,7 @@ def machine(self): @property def errors(self): - return self.machine.errors if self.machine else [] + return getattr(self.machine, "errors", []) @admin.display(boolean=True, description=_("Driver available")) def is_driver_available(self) -> bool: @@ -93,6 +93,11 @@ def no_errors(self) -> bool: """Status if machine has errors""" return len(self.errors) == 0 + @admin.display(boolean=True, description=_("Initialized")) + def initialized(self) -> bool: + """Status if machine is initialized""" + return getattr(self.machine, "initialized", False) + @admin.display(description=_("Errors")) def get_admin_errors(self): return format_html_join(mark_safe("
"), "{}", ((str(error),) for error in self.errors)) or mark_safe(f"{_('No errors')}") diff --git a/InvenTree/machine/registry.py b/InvenTree/machine/registry.py index 7921d7c0005..b486d6ba16b 100644 --- a/InvenTree/machine/registry.py +++ b/InvenTree/machine/registry.py @@ -127,18 +127,19 @@ def remove_machine(self, machine: BaseMachineType): self.machines.pop(str(machine.pk), None) def get_machines(self, **kwargs): - """Get loaded machines from registry. (By default only active machines) + """Get loaded machines from registry. (By default only initialized machines) Kwargs: name: Machine name machine_type: Machine type definition (class) driver: Machine driver (class) - active: (bool, default: True) + initialized: (bool, default: True) + active: (bool) base_driver: base driver (class | List[class]) """ - allowed_fields = ["name", "machine_type", "driver", "active", "base_driver"] + allowed_fields = ["name", "machine_type", "driver", "initialized", "active", "base_driver"] - kwargs = {**{'active': True}, **kwargs} + kwargs = {'initialized': True, **kwargs} def filter_machine(machine: BaseMachineType): for key, value in kwargs.items(): diff --git a/InvenTree/machine/serializers.py b/InvenTree/machine/serializers.py index e82d610b117..8dc7a3be616 100644 --- a/InvenTree/machine/serializers.py +++ b/InvenTree/machine/serializers.py @@ -38,22 +38,16 @@ class Meta: is_driver_available = serializers.SerializerMethodField("get_is_driver_available") def get_initialized(self, obj: MachineConfig) -> bool: - if obj.machine: - return obj.machine.initialized - return False + return getattr(obj.machine, "initialized", False) def get_status(self, obj: MachineConfig) -> int: - if obj.machine: - return obj.machine.status - return -1 + return getattr(obj.machine, "status", -1) def get_status_text(self, obj: MachineConfig) -> str: - if obj.machine: - return obj.machine.status_text - return "" + return getattr(obj.machine, "status_text", "") def get_errors(self, obj: MachineConfig) -> List[str]: - return obj.errors + return [str(err) for err in obj.errors] def get_is_driver_available(self, obj: MachineConfig) -> bool: return obj.is_driver_available() From bffd764146b4bba656d1d068c56caf00cb2f48c6 Mon Sep 17 00:00:00 2001 From: wolflu05 <76838159+wolflu05@users.noreply.github.com> Date: Tue, 11 Jul 2023 21:27:25 +0000 Subject: [PATCH 31/86] Fix: comment --- InvenTree/machine/machine_type.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/InvenTree/machine/machine_type.py b/InvenTree/machine/machine_type.py index cccb78ff192..d31f432ce46 100644 --- a/InvenTree/machine/machine_type.py +++ b/InvenTree/machine/machine_type.py @@ -79,7 +79,7 @@ def update_machine(self, old_machine_state: Dict[str, Any], machine: "BaseMachin pass def get_machines(self, **kwargs): - """Return all machines using this driver. (By default only active machines)""" + """Return all machines using this driver. (By default only initialized machines)""" from machine import registry return registry.get_machines(driver=self, **kwargs) From d483cfa5aada4c4e847f37e26b463a01c7be27c3 Mon Sep 17 00:00:00 2001 From: wolflu05 <76838159+wolflu05@users.noreply.github.com> Date: Wed, 12 Jul 2023 10:33:25 +0000 Subject: [PATCH 32/86] Fix initialize error and python 3.9 compability --- InvenTree/machine/machine_type.py | 7 +++++-- InvenTree/machine/registry.py | 2 +- InvenTree/machine/serializers.py | 4 ++-- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/InvenTree/machine/machine_type.py b/InvenTree/machine/machine_type.py index d31f432ce46..94d04c792aa 100644 --- a/InvenTree/machine/machine_type.py +++ b/InvenTree/machine/machine_type.py @@ -59,7 +59,10 @@ class BaseDriver(ClassValidationMixin, ClassProviderMixin): required_attributes = ["SLUG", "NAME", "DESCRIPTION", "machine_type"] def init_machine(self, machine: "BaseMachineType"): - """This method gets called for each active machine using that driver while initialization + """This method gets called for each active machine using that driver while initialization. + + If this function raises an Exception, it gets added to the machine.errors + list and the machine does not initialize successfully. Arguments: machine: Machine instance @@ -227,7 +230,7 @@ def check_settings(self): is_valid, missing = MachineSetting.check_all_settings(settings_definition=settings, machine_config=self.machine_config, config_type=config_type) missing_settings[config_type] = missing - return all(len(missing) == 0 for missing in missing_settings), missing_settings + return all(len(missing) == 0 for missing in missing_settings.values()), missing_settings def set_status(self, status: MachineStatus): """Set the machine status code. There are predefined ones for each MachineType. diff --git a/InvenTree/machine/registry.py b/InvenTree/machine/registry.py index b486d6ba16b..fcd3454224b 100644 --- a/InvenTree/machine/registry.py +++ b/InvenTree/machine/registry.py @@ -135,7 +135,7 @@ def get_machines(self, **kwargs): driver: Machine driver (class) initialized: (bool, default: True) active: (bool) - base_driver: base driver (class | List[class]) + base_driver: base driver (class) """ allowed_fields = ["name", "machine_type", "driver", "initialized", "active", "base_driver"] diff --git a/InvenTree/machine/serializers.py b/InvenTree/machine/serializers.py index 8dc7a3be616..b1145c94737 100644 --- a/InvenTree/machine/serializers.py +++ b/InvenTree/machine/serializers.py @@ -1,4 +1,4 @@ -from typing import List +from typing import List, Union from rest_framework import serializers @@ -107,7 +107,7 @@ class Meta: def get_provider_file(self, obj: ClassProviderMixin) -> str: return obj.get_provider_file() - def get_provider_plugin(self, obj: ClassProviderMixin) -> str | None: + def get_provider_plugin(self, obj: ClassProviderMixin) -> Union[str, None]: plugin = obj.get_provider_plugin() if plugin: return plugin.slug From 27db67db59c959226fdfaee2a79a4292c342f4f5 Mon Sep 17 00:00:00 2001 From: wolflu05 <76838159+wolflu05@users.noreply.github.com> Date: Wed, 8 Nov 2023 15:26:30 +0000 Subject: [PATCH 33/86] Make machine states available through the global states api --- InvenTree/generic/states/api.py | 16 +++++++++++----- .../machine_types/LabelPrintingMachineType.py | 6 ++++-- InvenTree/machine/migrations/0001_initial.py | 2 +- InvenTree/machine/serializers.py | 16 ++++++++++++++-- InvenTree/users/models.py | 2 ++ 5 files changed, 32 insertions(+), 10 deletions(-) diff --git a/InvenTree/generic/states/api.py b/InvenTree/generic/states/api.py index e5c07d51286..71a921dc57c 100644 --- a/InvenTree/generic/states/api.py +++ b/InvenTree/generic/states/api.py @@ -63,10 +63,16 @@ def get(self, request, *args, **kwargs): """Perform a GET request to learn information about status codes""" data = {} - for status_class in StatusCode.__subclasses__(): - data[status_class.__name__] = { - 'class': status_class.__name__, - 'values': status_class.dict(), - } + def discover_status_codes(parent_status_class, prefix=[]): + """Recursively discover status classes.""" + for status_class in parent_status_class.__subclasses__(): + name = "__".join([*prefix, status_class.__name__]) + data[name] = { + 'class': status_class.__name__, + 'values': status_class.dict(), + } + discover_status_codes(status_class, [name]) + + discover_status_codes(StatusCode) return Response(data) diff --git a/InvenTree/machine/machine_types/LabelPrintingMachineType.py b/InvenTree/machine/machine_types/LabelPrintingMachineType.py index efa756bb7e8..20f47babe29 100644 --- a/InvenTree/machine/machine_types/LabelPrintingMachineType.py +++ b/InvenTree/machine/machine_types/LabelPrintingMachineType.py @@ -26,10 +26,12 @@ class LabelPrintingMachineType(BaseMachineType): base_driver = BaseLabelPrintingDriver - class MACHINE_STATUS(MachineStatus): + class LabelPrinterStatus(MachineStatus): CONNECTED = 100, _("Connected"), "success" PRINTING = 101, _("Printing"), "primary" PAPER_MISSING = 301, _("Paper missing"), "warning" DISCONNECTED = 400, _("Disconnected"), "danger" - default_machine_status = MACHINE_STATUS.DISCONNECTED + MACHINE_STATUS = LabelPrinterStatus + + default_machine_status = LabelPrinterStatus.DISCONNECTED diff --git a/InvenTree/machine/migrations/0001_initial.py b/InvenTree/machine/migrations/0001_initial.py index 66a0a1e1c9c..cebcd8365d0 100644 --- a/InvenTree/machine/migrations/0001_initial.py +++ b/InvenTree/machine/migrations/0001_initial.py @@ -28,7 +28,7 @@ class Migration(migrations.Migration): fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('key', models.CharField(help_text='Settings key (must be unique - case insensitive)', max_length=50)), - ('value', models.CharField(blank=True, help_text='Settings value', max_length=200)), + ('value', models.CharField(blank=True, help_text='Settings value', max_length=2000)), ('config_type', models.CharField(choices=[('M', 'Machine'), ('D', 'Driver')], max_length=1, verbose_name='Config type')), ('machine_config', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='settings', to='machine.machineconfig', verbose_name='Machine Config')), ], diff --git a/InvenTree/machine/serializers.py b/InvenTree/machine/serializers.py index b1145c94737..0af9855bf64 100644 --- a/InvenTree/machine/serializers.py +++ b/InvenTree/machine/serializers.py @@ -21,6 +21,7 @@ class Meta: "initialized", "active", "status", + "status_model", "status_text", "machine_errors", "is_driver_available", @@ -29,10 +30,13 @@ class Meta: read_only_fields = [ "machine_type", "driver", + "status", + "status_model", ] initialized = serializers.SerializerMethodField("get_initialized") status = serializers.SerializerMethodField("get_status") + status_model = serializers.SerializerMethodField("get_status_model") status_text = serializers.SerializerMethodField("get_status_text") machine_errors = serializers.SerializerMethodField("get_errors") is_driver_available = serializers.SerializerMethodField("get_is_driver_available") @@ -41,7 +45,15 @@ def get_initialized(self, obj: MachineConfig) -> bool: return getattr(obj.machine, "initialized", False) def get_status(self, obj: MachineConfig) -> int: - return getattr(obj.machine, "status", -1) + status = getattr(obj.machine, "status", None) + if status is not None: + return status.value + return -1 + + def get_status_model(self, obj: MachineConfig) -> Union[str, None]: + if obj.machine and obj.machine.MACHINE_STATUS: + return obj.machine.MACHINE_STATUS.__name__ + return None def get_status_text(self, obj: MachineConfig) -> str: return getattr(obj.machine, "status_text", "") @@ -58,7 +70,7 @@ class MachineConfigCreateSerializer(MachineConfigSerializer): class Meta(MachineConfigSerializer.Meta): """Meta for serializer.""" - read_only_fields = [] + read_only_fields = list(set(MachineConfigSerializer.Meta.read_only_fields) - set(["machine_type", "driver"])) class MachineSettingSerializer(GenericReferencedSettingSerializer): diff --git a/InvenTree/users/models.py b/InvenTree/users/models.py index 803ed1c6147..96310ff0920 100644 --- a/InvenTree/users/models.py +++ b/InvenTree/users/models.py @@ -209,6 +209,8 @@ class RuleSet(models.Model): 'taggit_tag', 'taggit_taggeditem', 'flags_flagstate', + 'machine_machineconfig', + 'machine_machinesetting', ], 'part_category': [ 'part_partcategory', From 7b6229c1a576396d770b8b69bf579761e2d22a82 Mon Sep 17 00:00:00 2001 From: wolflu05 <76838159+wolflu05@users.noreply.github.com> Date: Wed, 8 Nov 2023 15:28:24 +0000 Subject: [PATCH 34/86] Added basic PUI machine admin implementation that is still in dev --- .../src/components/settings/SettingList.tsx | 29 +++ .../tables/machine/MachineListTable.tsx | 170 ++++++++++++++++++ .../src/pages/Index/Settings/AdminCenter.tsx | 10 ++ src/frontend/src/states/ApiState.tsx | 11 +- 4 files changed, 219 insertions(+), 1 deletion(-) create mode 100644 src/frontend/src/components/tables/machine/MachineListTable.tsx diff --git a/src/frontend/src/components/settings/SettingList.tsx b/src/frontend/src/components/settings/SettingList.tsx index 157a3ab6981..95cd88b95ab 100644 --- a/src/frontend/src/components/settings/SettingList.tsx +++ b/src/frontend/src/components/settings/SettingList.tsx @@ -1,6 +1,9 @@ import { Stack, Text } from '@mantine/core'; +import { useQuery } from '@tanstack/react-query'; import { useEffect } from 'react'; +import { api } from '../../App'; +import { ApiPaths, apiUrl } from '../../states/ApiState'; import { SettingsStateProps, useGlobalSettingsState, @@ -57,3 +60,29 @@ export function GlobalSettingList({ keys }: { keys: string[] }) { return ; } + +export function MachineSettingList({ pk }: { pk: string }) { + const { isLoading, isLoadingError, data } = useQuery({ + enabled: true, + queryKey: ['machine-detail', pk], + queryFn: () => { + console.log( + 'FETCH', + apiUrl(ApiPaths.machine_setting_list).replace('$id', pk) + ); + return api.get(apiUrl(ApiPaths.machine_setting_list).replace('$id', pk)); + }, + refetchOnMount: false, + refetchOnWindowFocus: false + }); + console.log(data); + if (isLoading) { + return

Loading

; + } + + if (isLoadingError) { + return

Error

; + } + + return

Loaded

; +} diff --git a/src/frontend/src/components/tables/machine/MachineListTable.tsx b/src/frontend/src/components/tables/machine/MachineListTable.tsx new file mode 100644 index 00000000000..da53d57629b --- /dev/null +++ b/src/frontend/src/components/tables/machine/MachineListTable.tsx @@ -0,0 +1,170 @@ +import { t } from '@lingui/macro'; +import { Button, Container, Group, Text, Tooltip } from '@mantine/core'; +import { + IconCircleCheck, + IconCircleX, + IconHelpCircle +} from '@tabler/icons-react'; +import { useCallback, useMemo, useState } from 'react'; + +import { notYetImplemented } from '../../../functions/notifications'; +import { useTableRefresh } from '../../../hooks/TableRefresh'; +import { ApiPaths, apiUrl } from '../../../states/ApiState'; +import { TableStatusRenderer } from '../../renderers/StatusRenderer'; +import { MachineSettingList } from '../../settings/SettingList'; +import { TableColumn } from '../Column'; +import { BooleanColumn, StatusColumn } from '../ColumnRenderers'; +import { InvenTreeTable, InvenTreeTableProps } from '../InvenTreeTable'; +import { RowAction } from '../RowActions'; + +interface MachineI { + pk: string; + name: string; + machine_type: string; + driver: string; + initialized: boolean; + active: boolean; + status: number; + status_model: string; + status_text: string; + machine_errors: string[]; + is_driver_available: boolean; +} + +function MachineDetail({ + machine, + goBack +}: { + machine: MachineI; + goBack: () => void; +}) { + return ( + + + {machine.name} + + + + ); +} + +/** + * Table displaying list of available plugins + */ +export function MachineListTable({ props }: { props: InvenTreeTableProps }) { + const { tableKey, refreshTable } = useTableRefresh('machine'); + + const machineTableColumns: TableColumn[] = useMemo( + () => [ + { + accessor: 'name', + title: t`Machine`, + sortable: true, + render: function (record: any) { + // TODO: Add link to machine detail page + // TODO: Add custom icon + return ( + + {record.name} + + ); + } + }, + { + accessor: 'machine_type', + title: t`Machine Type`, + sortable: true, + filtering: true + }, + { + accessor: 'driver', + title: t`Machine Driver`, + sortable: true, + filtering: true + }, + BooleanColumn({ + accessor: 'initialized', + title: t`Initialized` + }), + BooleanColumn({ + accessor: 'active', + title: t`Active` + }), + { + accessor: 'status', + title: t`Status`, + sortable: false, + render: (record) => { + const renderer = TableStatusRenderer( + `MachineStatus__${record.status_model}` as any + ); + if (renderer && record.status !== -1) { + return renderer(record); + } + } + }, + BooleanColumn({ + accessor: 'is_driver_available', + title: t`Driver available` + }) + ], + [] + ); + + // Determine available actions for a given plugin + function rowActions(record: any): RowAction[] { + let actions: RowAction[] = []; + + if (record.active) { + actions.push({ + title: t`Deactivate`, + color: 'red', + onClick: () => { + notYetImplemented(); + } + }); + } else { + actions.push({ + title: t`Activate`, + onClick: () => { + notYetImplemented(); + } + }); + } + + return actions; + } + + const [machineDetail, setMachineDetail] = useState(null); + const goBack = useCallback(() => setMachineDetail(null), []); + + if (machineDetail) { + return ; + } + + return ( + setMachineDetail(record), + params: { + ...props.params + }, + rowActions: rowActions, + customFilters: [ + { + name: 'active', + label: t`Active`, + type: 'boolean' + } + // TODO: add machine_type choices filter + // TODO: add driver choices filter + ] + }} + /> + ); +} diff --git a/src/frontend/src/pages/Index/Settings/AdminCenter.tsx b/src/frontend/src/pages/Index/Settings/AdminCenter.tsx index 681ceea6e64..022f0f432cb 100644 --- a/src/frontend/src/pages/Index/Settings/AdminCenter.tsx +++ b/src/frontend/src/pages/Index/Settings/AdminCenter.tsx @@ -16,6 +16,7 @@ import { PlaceholderPill } from '../../../components/items/Placeholder'; import { PanelGroup, PanelType } from '../../../components/nav/PanelGroup'; import { SettingsHeader } from '../../../components/nav/SettingsHeader'; import { GlobalSettingList } from '../../../components/settings/SettingList'; +import { MachineListTable } from '../../../components/tables/machine/MachineListTable'; /** * System settings page @@ -55,6 +56,15 @@ export default function AdminCenter() { /> ) + }, + { + name: 'machine', + label: t`Machine Management`, + content: ( + + + + ) } ]; }, []); diff --git a/src/frontend/src/states/ApiState.tsx b/src/frontend/src/states/ApiState.tsx index 186b55dafe0..9b0b957c2be 100644 --- a/src/frontend/src/states/ApiState.tsx +++ b/src/frontend/src/states/ApiState.tsx @@ -34,7 +34,8 @@ export const useServerApiState = create()( await api.get(apiUrl(ApiPaths.global_status)).then((response) => { const newStatusLookup: StatusLookup = {} as StatusLookup; for (const key in response.data) { - newStatusLookup[statusCodeList[key]] = response.data[key].values; + newStatusLookup[statusCodeList[key] || key] = + response.data[key].values; } set({ status: newStatusLookup }); }); @@ -125,6 +126,10 @@ export enum ApiPaths { // Plugin URLs plugin_list = 'api-plugin-list', + // Machine URLs + machine_list = 'api-machine-list', + machine_setting_list = 'api-machine-settings', + project_code_list = 'api-project-code-list', custom_unit_list = 'api-custom-unit-list' } @@ -246,6 +251,10 @@ export function apiEndpoint(path: ApiPaths): string { return 'order/ro/attachment/'; case ApiPaths.plugin_list: return 'plugins/'; + case ApiPaths.machine_list: + return 'machine/'; + case ApiPaths.machine_setting_list: + return 'machine/$id/settings/'; case ApiPaths.project_code_list: return 'project-code/'; case ApiPaths.custom_unit_list: From 265b712d649dc664636a1ef5744b1fc8f7471b55 Mon Sep 17 00:00:00 2001 From: wolflu05 <76838159+wolflu05@users.noreply.github.com> Date: Thu, 9 Nov 2023 10:13:26 +0000 Subject: [PATCH 35/86] Added basic machine setting UI to PUI --- InvenTree/InvenTree/metadata.py | 6 ++- InvenTree/machine/api.py | 1 + src/frontend/src/components/forms/ApiForm.tsx | 3 +- .../src/components/settings/SettingItem.tsx | 6 ++- .../src/components/settings/SettingList.tsx | 54 +++++++++---------- .../tables/machine/MachineListTable.tsx | 15 +++--- src/frontend/src/functions/forms.tsx | 2 +- .../AccountSettings/SecurityContent.tsx | 4 +- src/frontend/src/states/ApiState.tsx | 36 ++++++++++--- src/frontend/src/states/SettingsState.tsx | 46 +++++++++++++++- 10 files changed, 122 insertions(+), 51 deletions(-) diff --git a/InvenTree/InvenTree/metadata.py b/InvenTree/InvenTree/metadata.py index 2d4fb95024a..27b27ebf5f3 100644 --- a/InvenTree/InvenTree/metadata.py +++ b/InvenTree/InvenTree/metadata.py @@ -7,6 +7,7 @@ from rest_framework.metadata import SimpleMetadata from rest_framework.utils import model_meta +import common.models import InvenTree.permissions import users.models from InvenTree.helpers import str2bool @@ -209,7 +210,10 @@ def get_serializer_info(self, serializer): pk = kwargs[field] break - if pk is not None: + if issubclass(model_class, common.models.BaseInvenTreeSetting): + instance = model_class.get_setting_object(**kwargs, create=False) + + elif pk is not None: try: instance = model_class.objects.get(pk=pk) except (ValueError, model_class.DoesNotExist): diff --git a/InvenTree/machine/api.py b/InvenTree/machine/api.py index c1e27983a2d..f2c99a80a09 100644 --- a/InvenTree/machine/api.py +++ b/InvenTree/machine/api.py @@ -121,6 +121,7 @@ class MachineSettingDetail(RetrieveUpdateAPI): (Note that these cannot be created or deleted via API) """ + lookup_field = 'key' queryset = MachineSetting.objects.all() serializer_class = MachineSerializers.MachineSettingSerializer diff --git a/src/frontend/src/components/forms/ApiForm.tsx b/src/frontend/src/components/forms/ApiForm.tsx index d9daf89b513..523dc624a0c 100644 --- a/src/frontend/src/components/forms/ApiForm.tsx +++ b/src/frontend/src/components/forms/ApiForm.tsx @@ -11,7 +11,7 @@ import { useState } from 'react'; import { api, queryClient } from '../../App'; import { constructFormUrl } from '../../functions/forms'; import { invalidResponse } from '../../functions/notifications'; -import { ApiPaths } from '../../states/ApiState'; +import { ApiPaths, PathParams } from '../../states/ApiState'; import { ApiFormField, ApiFormFieldSet } from './fields/ApiFormField'; /** @@ -36,6 +36,7 @@ import { ApiFormField, ApiFormFieldSet } from './fields/ApiFormField'; export interface ApiFormProps { url: ApiPaths; pk?: number | string | undefined; + pathParams?: PathParams; title: string; fields?: ApiFormFieldSet; cancelText?: string; diff --git a/src/frontend/src/components/settings/SettingItem.tsx b/src/frontend/src/components/settings/SettingItem.tsx index b92df308796..77f2d1eb1df 100644 --- a/src/frontend/src/components/settings/SettingItem.tsx +++ b/src/frontend/src/components/settings/SettingItem.tsx @@ -23,7 +23,10 @@ function SettingValue({ // Callback function when a boolean value is changed function onToggle(value: boolean) { api - .patch(apiUrl(settingsState.endpoint, setting.key), { value: value }) + .patch( + apiUrl(settingsState.endpoint, setting.key, settingsState.pathParams), + { value: value } + ) .then(() => { showNotification({ title: t`Setting updated`, @@ -53,6 +56,7 @@ function SettingValue({ openModalApiForm({ url: settingsState.endpoint, pk: setting.key, + pathParams: settingsState.pathParams, method: 'PATCH', title: t`Edit Setting`, ignorePermissionCheck: true, diff --git a/src/frontend/src/components/settings/SettingList.tsx b/src/frontend/src/components/settings/SettingList.tsx index 95cd88b95ab..02bad34b0b8 100644 --- a/src/frontend/src/components/settings/SettingList.tsx +++ b/src/frontend/src/components/settings/SettingList.tsx @@ -1,11 +1,10 @@ import { Stack, Text } from '@mantine/core'; -import { useQuery } from '@tanstack/react-query'; -import { useEffect } from 'react'; +import { useEffect, useMemo, useRef } from 'react'; +import { useStore } from 'zustand'; -import { api } from '../../App'; -import { ApiPaths, apiUrl } from '../../states/ApiState'; import { SettingsStateProps, + createMachineSettingsState, useGlobalSettingsState, useUserSettingsState } from '../../states/SettingsState'; @@ -19,16 +18,21 @@ export function SettingList({ keys }: { settingsState: SettingsStateProps; - keys: string[]; + keys?: string[]; }) { useEffect(() => { settingsState.fetchSettings(); }, []); + const allKeys = useMemo( + () => settingsState?.settings?.map((s) => s.key), + [settingsState?.settings] + ); + return ( <> - {keys.map((key) => { + {(keys || allKeys).map((key) => { const setting = settingsState?.settings?.find( (s: any) => s.key === key ); @@ -61,28 +65,20 @@ export function GlobalSettingList({ keys }: { keys: string[] }) { return ; } -export function MachineSettingList({ pk }: { pk: string }) { - const { isLoading, isLoadingError, data } = useQuery({ - enabled: true, - queryKey: ['machine-detail', pk], - queryFn: () => { - console.log( - 'FETCH', - apiUrl(ApiPaths.machine_setting_list).replace('$id', pk) - ); - return api.get(apiUrl(ApiPaths.machine_setting_list).replace('$id', pk)); - }, - refetchOnMount: false, - refetchOnWindowFocus: false - }); - console.log(data); - if (isLoading) { - return

Loading

; - } - - if (isLoadingError) { - return

Error

; - } +export function MachineSettingList({ + machinePk, + configType +}: { + machinePk: string; + configType: 'M' | 'D'; +}) { + const machineSettingsStore = useRef( + createMachineSettingsState({ + machine: machinePk, + configType: configType + }) + ).current; + const machineSettings = useStore(machineSettingsStore); - return

Loaded

; + return ; } diff --git a/src/frontend/src/components/tables/machine/MachineListTable.tsx b/src/frontend/src/components/tables/machine/MachineListTable.tsx index da53d57629b..0989dfbbdb2 100644 --- a/src/frontend/src/components/tables/machine/MachineListTable.tsx +++ b/src/frontend/src/components/tables/machine/MachineListTable.tsx @@ -1,10 +1,5 @@ import { t } from '@lingui/macro'; -import { Button, Container, Group, Text, Tooltip } from '@mantine/core'; -import { - IconCircleCheck, - IconCircleX, - IconHelpCircle -} from '@tabler/icons-react'; +import { Button, Container, Divider, Group, Text } from '@mantine/core'; import { useCallback, useMemo, useState } from 'react'; import { notYetImplemented } from '../../../functions/notifications'; @@ -13,7 +8,7 @@ import { ApiPaths, apiUrl } from '../../../states/ApiState'; import { TableStatusRenderer } from '../../renderers/StatusRenderer'; import { MachineSettingList } from '../../settings/SettingList'; import { TableColumn } from '../Column'; -import { BooleanColumn, StatusColumn } from '../ColumnRenderers'; +import { BooleanColumn } from '../ColumnRenderers'; import { InvenTreeTable, InvenTreeTableProps } from '../InvenTreeTable'; import { RowAction } from '../RowActions'; @@ -43,7 +38,11 @@ function MachineDetail({ {machine.name} - + Machine Settings + + + Driver Settings + ); } diff --git a/src/frontend/src/functions/forms.tsx b/src/frontend/src/functions/forms.tsx index d9fb12a2568..a862de91d30 100644 --- a/src/frontend/src/functions/forms.tsx +++ b/src/frontend/src/functions/forms.tsx @@ -15,7 +15,7 @@ import { generateUniqueId } from './uid'; * Construct an API url from the provided ApiFormProps object */ export function constructFormUrl(props: ApiFormProps): string { - return apiUrl(props.url, props.pk); + return apiUrl(props.url, props.pk, props.pathParams); } /** diff --git a/src/frontend/src/pages/Index/Settings/AccountSettings/SecurityContent.tsx b/src/frontend/src/pages/Index/Settings/AccountSettings/SecurityContent.tsx index ddf52400c37..eb2b9362779 100644 --- a/src/frontend/src/pages/Index/Settings/AccountSettings/SecurityContent.tsx +++ b/src/frontend/src/pages/Index/Settings/AccountSettings/SecurityContent.tsx @@ -97,7 +97,7 @@ function EmailContent({}: {}) { function runServerAction(url: ApiPaths) { api - .post(apiUrl(url).replace('$id', value), {}) + .post(apiUrl(url, undefined, { id: value }), {}) .then(() => { refetch(); }) @@ -218,7 +218,7 @@ function SsoContent({ dataProvider }: { dataProvider: any | undefined }) { function removeProvider() { api - .post(apiUrl(ApiPaths.user_sso_remove).replace('$id', value)) + .post(apiUrl(ApiPaths.user_sso_remove, undefined, { id: value })) .then(() => { queryClient.removeQueries({ queryKey: ['sso-list'] diff --git a/src/frontend/src/states/ApiState.tsx b/src/frontend/src/states/ApiState.tsx index 9b0b957c2be..49a973bab00 100644 --- a/src/frontend/src/states/ApiState.tsx +++ b/src/frontend/src/states/ApiState.tsx @@ -127,8 +127,12 @@ export enum ApiPaths { plugin_list = 'api-plugin-list', // Machine URLs + machine_types_list = 'api-machine-types', + machine_driver_list = 'api-machine-drivers', + machine_registry_status = 'api-machine-registry-status', machine_list = 'api-machine-list', machine_setting_list = 'api-machine-settings', + machine_setting_detail = 'api-machine-settings-detail', project_code_list = 'api-project-code-list', custom_unit_list = 'api-custom-unit-list' @@ -166,15 +170,15 @@ export function apiEndpoint(path: ApiPaths): string { case ApiPaths.user_sso: return 'auth/social/'; case ApiPaths.user_sso_remove: - return 'auth/social/$id/disconnect/'; + return 'auth/social/:id/disconnect/'; case ApiPaths.user_emails: return 'auth/emails/'; case ApiPaths.user_email_remove: - return 'auth/emails/$id/remove/'; + return 'auth/emails/:id/remove/'; case ApiPaths.user_email_verify: - return 'auth/emails/$id/verify/'; + return 'auth/emails/:id/verify/'; case ApiPaths.user_email_primary: - return 'auth/emails/$id/primary/'; + return 'auth/emails/:id/primary/'; case ApiPaths.currency_list: return 'currency/exchange/'; case ApiPaths.currency_refresh: @@ -251,10 +255,18 @@ export function apiEndpoint(path: ApiPaths): string { return 'order/ro/attachment/'; case ApiPaths.plugin_list: return 'plugins/'; + case ApiPaths.machine_types_list: + return 'machine/types/'; + case ApiPaths.machine_driver_list: + return 'machine/drivers/'; + case ApiPaths.machine_registry_status: + return 'machine/status/'; case ApiPaths.machine_list: return 'machine/'; case ApiPaths.machine_setting_list: - return 'machine/$id/settings/'; + return 'machine/:machine/settings/'; + case ApiPaths.machine_setting_detail: + return 'machine/:machine/settings/:config_type/'; case ApiPaths.project_code_list: return 'project-code/'; case ApiPaths.custom_unit_list: @@ -264,10 +276,16 @@ export function apiEndpoint(path: ApiPaths): string { } } +export type PathParams = Record; + /** * Construct an API URL with an endpoint and (optional) pk value */ -export function apiUrl(path: ApiPaths, pk?: any): string { +export function apiUrl( + path: ApiPaths, + pk?: any, + pathParams?: PathParams +): string { let _url = apiEndpoint(path); // If the URL does not start with a '/', add the API prefix @@ -279,5 +297,11 @@ export function apiUrl(path: ApiPaths, pk?: any): string { _url += `${pk}/`; } + if (pathParams) { + for (const [key, value] of Object.entries(pathParams)) { + _url = _url.replace(`:${key}`, `${value}`); + } + } + return _url; } diff --git a/src/frontend/src/states/SettingsState.tsx b/src/frontend/src/states/SettingsState.tsx index 622644ff29f..2db8b8d986b 100644 --- a/src/frontend/src/states/SettingsState.tsx +++ b/src/frontend/src/states/SettingsState.tsx @@ -1,10 +1,10 @@ /** * State management for remote (server side) settings */ -import { create } from 'zustand'; +import { create, createStore } from 'zustand'; import { api } from '../App'; -import { ApiPaths, apiUrl } from './ApiState'; +import { ApiPaths, PathParams, apiUrl } from './ApiState'; import { Setting, SettingsLookup } from './states'; export interface SettingsStateProps { @@ -12,6 +12,7 @@ export interface SettingsStateProps { lookup: SettingsLookup; fetchSettings: () => void; endpoint: ApiPaths; + pathParams?: PathParams; } /** @@ -60,6 +61,47 @@ export const useUserSettingsState = create((set, get) => ({ } })); +/** + * State management for machine settings + */ +interface CreateMachineSettingStateProps { + machine: string; + configType: 'M' | 'D'; +} + +export const createMachineSettingsState = ({ + machine, + configType +}: CreateMachineSettingStateProps) => { + const pathParams: PathParams = { machine, config_type: configType }; + + return createStore()((set, get) => ({ + settings: [], + lookup: {}, + endpoint: ApiPaths.machine_setting_detail, + pathParams, + fetchSettings: async () => { + await api + .get(apiUrl(ApiPaths.machine_setting_list, undefined, { machine })) + .then((response) => { + const settings = response.data.filter( + (s: any) => s.config_type === configType + ); + set({ + settings, + lookup: generate_lookup(settings) + }); + }) + .catch((error) => { + console.error( + `Error fetching machine settings for machine ${machine} with type ${configType}:`, + error + ); + }); + } + })); +}; + /* return a lookup dictionary for the value of the provided Setting list */ From 158dcdda21f1bc9187201e88a150b6aaea587411 Mon Sep 17 00:00:00 2001 From: wolflu05 <76838159+wolflu05@users.noreply.github.com> Date: Thu, 9 Nov 2023 19:24:54 +0000 Subject: [PATCH 36/86] Added machine detail view to PUI admin center --- InvenTree/machine/api.py | 24 +- InvenTree/machine/serializers.py | 2 - .../components/items/UnavailableIndicator.tsx | 5 + .../src/components/settings/SettingList.tsx | 21 +- src/frontend/src/components/tables/Column.tsx | 4 +- .../src/components/tables/InvenTreeTable.tsx | 14 +- .../tables/machine/MachineListTable.tsx | 445 +++++++++++++++--- 7 files changed, 422 insertions(+), 93 deletions(-) create mode 100644 src/frontend/src/components/items/UnavailableIndicator.tsx diff --git a/InvenTree/machine/api.py b/InvenTree/machine/api.py index f2c99a80a09..66ada4378a5 100644 --- a/InvenTree/machine/api.py +++ b/InvenTree/machine/api.py @@ -7,7 +7,6 @@ from rest_framework.views import APIView import machine.serializers as MachineSerializers -from generic.states.api import StatusView from InvenTree.filters import SEARCH_ORDER_FILTER from InvenTree.mixins import (ListCreateAPI, RetrieveUpdateAPI, RetrieveUpdateDestroyAPI) @@ -155,21 +154,6 @@ def get(self, request): return Response(results) -class MachineTypeStatusView(StatusView): - """List all status codes for a machine type. - - - GET: List all status codes for this machine type - """ - - def get_status_model(self, *args, **kwargs): - # dynamically inject the StatusCode model from the machine type url param - machine_type = registry.machine_types.get(self.kwargs["machine_type"], None) - if machine_type is None: - raise NotFound(detail=f"Machine type '{self.kwargs['machine_type']}' not found") - self.kwargs[self.MODEL_REF] = machine_type.MACHINE_STATUS - return super().get_status_model(*args, **kwargs) - - class MachineDriverList(APIView): """List API Endpoint for all discovered machine drivers. @@ -198,6 +182,7 @@ class RegistryStatusView(APIView): serializer_class = MachineSerializers.MachineRegistryStatusSerializer + @extend_schema(responses={200: MachineSerializers.MachineRegistryStatusSerializer()}) def get(self, request): result = MachineSerializers.MachineRegistryStatusSerializer({ "registry_errors": list(map(str, registry.errors)) @@ -208,12 +193,7 @@ def get(self, request): machine_api_urls = [ # machine types - re_path(r"^types/", include([ - path(r"/", include([ - re_path(r"^status/", MachineTypeStatusView.as_view(), name="api-machine-type-status"), - ])), - re_path(r"^.*$", MachineTypesList.as_view(), name="api-machine-types"), - ])), + re_path(r"^types/", MachineTypesList.as_view(), name="api-machine-types"), # machine drivers re_path(r"^drivers/", MachineDriverList.as_view(), name="api-machine-drivers"), diff --git a/InvenTree/machine/serializers.py b/InvenTree/machine/serializers.py index 0af9855bf64..40d8c1704bf 100644 --- a/InvenTree/machine/serializers.py +++ b/InvenTree/machine/serializers.py @@ -30,8 +30,6 @@ class Meta: read_only_fields = [ "machine_type", "driver", - "status", - "status_model", ] initialized = serializers.SerializerMethodField("get_initialized") diff --git a/src/frontend/src/components/items/UnavailableIndicator.tsx b/src/frontend/src/components/items/UnavailableIndicator.tsx new file mode 100644 index 00000000000..41a9b953be7 --- /dev/null +++ b/src/frontend/src/components/items/UnavailableIndicator.tsx @@ -0,0 +1,5 @@ +import { IconAlertCircle } from '@tabler/icons-react'; + +export function UnavailableIndicator() { + return ; +} diff --git a/src/frontend/src/components/settings/SettingList.tsx b/src/frontend/src/components/settings/SettingList.tsx index 02bad34b0b8..39a6c765ecb 100644 --- a/src/frontend/src/components/settings/SettingList.tsx +++ b/src/frontend/src/components/settings/SettingList.tsx @@ -1,4 +1,9 @@ -import { Stack, Text } from '@mantine/core'; +import { + Stack, + Text, + useMantineColorScheme, + useMantineTheme +} from '@mantine/core'; import { useEffect, useMemo, useRef } from 'react'; import { useStore } from 'zustand'; @@ -29,15 +34,25 @@ export function SettingList({ [settingsState?.settings] ); + const theme = useMantineTheme(); + return ( <> - {(keys || allKeys).map((key) => { + {(keys || allKeys).map((key, i) => { const setting = settingsState?.settings?.find( (s: any) => s.key === key ); + + const style: Record = { paddingLeft: '8px' }; + if (i % 2 === 0) + style['backgroundColor'] = + theme.colorScheme === 'light' + ? theme.colors.gray[1] + : theme.colors.gray[9]; + return ( -
+
{setting ? ( ) : ( diff --git a/src/frontend/src/components/tables/Column.tsx b/src/frontend/src/components/tables/Column.tsx index 460386c2dc9..4917689b996 100644 --- a/src/frontend/src/components/tables/Column.tsx +++ b/src/frontend/src/components/tables/Column.tsx @@ -1,14 +1,14 @@ /** * Interface for the table column definition */ -export type TableColumn = { +export type TableColumn = { accessor: string; // The key in the record to access ordering?: string; // The key in the record to sort by (defaults to accessor) title: string; // The title of the column sortable?: boolean; // Whether the column is sortable switchable?: boolean; // Whether the column is switchable hidden?: boolean; // Whether the column is hidden - render?: (record: any) => any; // A custom render function + render?: (record: T) => any; // A custom render function filter?: any; // A custom filter function filtering?: boolean; // Whether the column is filterable width?: number; // The width of the column diff --git a/src/frontend/src/components/tables/InvenTreeTable.tsx b/src/frontend/src/components/tables/InvenTreeTable.tsx index 51cd0ae6eba..536d9092adf 100644 --- a/src/frontend/src/components/tables/InvenTreeTable.tsx +++ b/src/frontend/src/components/tables/InvenTreeTable.tsx @@ -44,7 +44,7 @@ const defaultPageSize: number = 25; * @param rowActions : (record: any) => RowAction[] - Callback function to generate row actions * @param onRowClick : (record: any, index: number, event: any) => void - Callback function when a row is clicked */ -export type InvenTreeTableProps = { +export type InvenTreeTableProps = { params?: any; defaultSortColumn?: string; noRecordsText?: string; @@ -60,9 +60,9 @@ export type InvenTreeTableProps = { customActionGroups?: any[]; printingActions?: any[]; idAccessor?: string; - dataFormatter?: (data: any) => any; - rowActions?: (record: any) => RowAction[]; - onRowClick?: (record: any, index: number, event: any) => void; + dataFormatter?: (data: T) => any; + rowActions?: (record: T) => RowAction[]; + onRowClick?: (record: T, index: number, event: any) => void; }; /** @@ -90,7 +90,7 @@ const defaultInvenTreeTableProps: InvenTreeTableProps = { /** * Table Component which extends DataTable with custom InvenTree functionality */ -export function InvenTreeTable({ +export function InvenTreeTable({ url, tableKey, columns, @@ -98,8 +98,8 @@ export function InvenTreeTable({ }: { url: string; tableKey: string; - columns: TableColumn[]; - props: InvenTreeTableProps; + columns: TableColumn[]; + props: InvenTreeTableProps; }) { // Use the first part of the table key as the table name const tableName: string = useMemo(() => { diff --git a/src/frontend/src/components/tables/machine/MachineListTable.tsx b/src/frontend/src/components/tables/machine/MachineListTable.tsx index 0989dfbbdb2..9323ab094e3 100644 --- a/src/frontend/src/components/tables/machine/MachineListTable.tsx +++ b/src/frontend/src/components/tables/machine/MachineListTable.tsx @@ -1,11 +1,58 @@ -import { t } from '@lingui/macro'; -import { Button, Container, Divider, Group, Text } from '@mantine/core'; -import { useCallback, useMemo, useState } from 'react'; +import { Trans, t } from '@lingui/macro'; +import { + ActionIcon, + Badge, + Box, + Button, + Card, + CheckIcon, + Code, + Container, + Divider, + Flex, + Group, + Indicator, + List, + LoadingOverlay, + Space, + Stack, + Text, + Title, + Tooltip +} from '@mantine/core'; +import { + IconAlertCircle, + IconChevronLeft, + IconDots, + IconPlus, + IconRefresh +} from '@tabler/icons-react'; +import { useQuery } from '@tanstack/react-query'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { api } from '../../../App'; +import { + openCreateApiForm, + openDeleteApiForm, + openEditApiForm +} from '../../../functions/forms'; import { notYetImplemented } from '../../../functions/notifications'; import { useTableRefresh } from '../../../hooks/TableRefresh'; import { ApiPaths, apiUrl } from '../../../states/ApiState'; -import { TableStatusRenderer } from '../../renderers/StatusRenderer'; +import { AddItemButton } from '../../buttons/AddItemButton'; +import { ButtonMenu } from '../../buttons/ButtonMenu'; +import { ApiFormProps } from '../../forms/ApiForm'; +import { + ActionDropdown, + DeleteItemAction, + EditItemAction +} from '../../items/ActionDropdown'; +import { UnavailableIndicator } from '../../items/UnavailableIndicator'; +import { YesNoButton } from '../../items/YesNoButton'; +import { + StatusRenderer, + TableStatusRenderer +} from '../../renderers/StatusRenderer'; import { MachineSettingList } from '../../settings/SettingList'; import { TableColumn } from '../Column'; import { BooleanColumn } from '../ColumnRenderers'; @@ -26,24 +73,240 @@ interface MachineI { is_driver_available: boolean; } +interface MachineTypeI { + slug: string; + name: string; + description: string; + provider_file: string; + provider_plugin: string; + is_builtin: boolean; +} + +interface MachineDriverI { + slug: string; + name: string; + description: string; + provider_file: string; + provider_plugin: string; + is_builtin: boolean; + machine_type: string; +} + +function MachineStatusIndicator({ machine }: { machine: MachineI }) { + const sx = { marginLeft: '4px' }; + + // machine is not active, show a gray dot + if (!machine.active) { + return ( + + + + ); + } + + // determine the status color + let color = 'green'; + const hasErrors = + machine.machine_errors.length > 0 || !machine.is_driver_available; + + if (hasErrors || machine.status >= 300) color = 'red'; + else if (machine.status >= 200) color = 'orange'; + + // determine if the machine is running + const processing = + machine.initialized && machine.status > 0 && machine.status < 300; + + return ( + + + + ); +} + function MachineDetail({ - machine, + machinePk, goBack }: { - machine: MachineI; + machinePk: string; goBack: () => void; }) { + const { + data: machine, + refetch, + isFetching: isMachineFetching + } = useQuery({ + enabled: true, + queryKey: ['machine-detail', machinePk], + queryFn: () => + api.get(apiUrl(ApiPaths.machine_list, machinePk)).then((res) => res.data) + }); + const { data: machineTypes, isFetching: isMachineTypesFetching } = useQuery< + MachineTypeI[] + >({ + queryKey: ['machine-types'], + queryFn: () => + api.get(apiUrl(ApiPaths.machine_types_list)).then((res) => res.data), + staleTime: 10 * 1000 + }); + + const isFetching = isMachineFetching || isMachineTypesFetching; + + function InfoItem({ + name, + children + }: { + name: string; + children: React.ReactNode; + }) { + return ( + + + {name}: + + {children} + + ); + } + return ( - - - {machine.name} - - Machine Settings - - - Driver Settings - - + + + + + + + + + + {machine && } + {machine?.name} + + + } + actions={[ + EditItemAction({ + tooltip: t`Edit machine`, + onClick: () => { + openEditApiForm({ + title: t`Edit machine`, + url: ApiPaths.machine_list, + pk: machinePk, + fields: { + name: {}, + active: {} + }, + onClose: () => refetch() + }); + } + }), + DeleteItemAction({ + tooltip: t`Delete machine`, + onClick: () => { + openDeleteApiForm({ + title: t`Delete machine`, + successMessage: t`Machine successfully deleted.`, + url: ApiPaths.machine_list, + pk: machinePk, + preFormContent: ( + {t`Are you sure you want to remove the machine "${machine?.name}"?`} + ), + onFormSuccess: () => goBack() + }); + } + }) + ]} + /> + + + + + + + <Trans>Info</Trans> + + refetch()}> + + + + + + + + {machine?.machine_type} + {machine && + machineTypes && + machineTypes.findIndex( + (m) => m.slug === machine.machine_type + ) === -1 && } + + + + + {machine?.driver} + {!machine?.is_driver_available && } + + + + + + + + + + + {machine?.status === -1 ? ( + No status + ) : ( + StatusRenderer({ + status: `${machine?.status || -1}`, + type: `MachineStatus__${machine?.status_model}` as any + }) + )} + {machine?.status_text} + + + + + Errors: + + {machine && machine?.machine_errors.length > 0 ? ( + + {machine?.machine_errors.length} + + ) : ( + + No errors reported + + )} + + {machine?.machine_errors.map((error) => ( + + {error} + + ))} + + + + + + + + {machine?.is_driver_available && ( + <> + + <Trans>Machine Settings</Trans> + + + + + <Trans>Driver Settings</Trans> + + + + )} + ); } @@ -51,19 +314,31 @@ function MachineDetail({ * Table displaying list of available plugins */ export function MachineListTable({ props }: { props: InvenTreeTableProps }) { + const { data: machineTypes } = useQuery({ + queryKey: ['machine-types'], + queryFn: () => + api.get(apiUrl(ApiPaths.machine_types_list)).then((res) => res.data), + staleTime: 10 * 1000 + }); + const { data: machineDrivers } = useQuery({ + queryKey: ['machine-drivers'], + queryFn: () => + api.get(apiUrl(ApiPaths.machine_driver_list)).then((res) => res.data), + staleTime: 10 * 1000 + }); + const { tableKey, refreshTable } = useTableRefresh('machine'); - const machineTableColumns: TableColumn[] = useMemo( + const machineTableColumns = useMemo[]>( () => [ { accessor: 'name', title: t`Machine`, sortable: true, - render: function (record: any) { - // TODO: Add link to machine detail page - // TODO: Add custom icon + render: function (record) { return ( + {record.name} ); @@ -73,13 +348,30 @@ export function MachineListTable({ props }: { props: InvenTreeTableProps }) { accessor: 'machine_type', title: t`Machine Type`, sortable: true, - filtering: true + render: (record) => { + return ( + + {record.machine_type} + {machineTypes && + machineTypes.findIndex( + (m: any) => m.slug === record.machine_type + ) === -1 && } + + ); + } }, { accessor: 'driver', title: t`Machine Driver`, sortable: true, - filtering: true + render: (record) => { + return ( + + {record.driver} + {!record.is_driver_available && } + + ); + } }, BooleanColumn({ accessor: 'initialized', @@ -101,44 +393,59 @@ export function MachineListTable({ props }: { props: InvenTreeTableProps }) { return renderer(record); } } - }, - BooleanColumn({ - accessor: 'is_driver_available', - title: t`Driver available` - }) + } ], - [] + [machineTypes] ); - // Determine available actions for a given plugin - function rowActions(record: any): RowAction[] { - let actions: RowAction[] = []; + const [createFormMachineType, setCreateFormMachineType] = useState< + null | string + >(null); + const createFormDriverOptions = useMemo(() => { + if (!machineDrivers) return []; - if (record.active) { - actions.push({ - title: t`Deactivate`, - color: 'red', - onClick: () => { - notYetImplemented(); - } - }); - } else { - actions.push({ - title: t`Activate`, - onClick: () => { - notYetImplemented(); - } - }); - } + return machineDrivers + .filter((d) => d.machine_type === createFormMachineType) + .map((d) => ({ + value: d.slug, + display_name: `${d.name} (${d.description})` + })); + }, [machineDrivers, createFormMachineType]); - return actions; - } + const createMachineForm = useMemo(() => { + return { + title: t`Create machine`, + url: ApiPaths.machine_list, + fields: { + name: {}, + machine_type: { + field_type: 'choice', + choices: machineTypes + ? machineTypes.map((t) => ({ + value: t.slug, + display_name: `${t.name} (${t.description})` + })) + : [], + onValueChange: ({ value }) => setCreateFormMachineType(value) + }, + driver: { + field_type: 'choice', + disabled: !createFormMachineType, + choices: createFormDriverOptions + }, + active: {} + }, + onClose: () => { + setCreateFormMachineType(null); + } + }; + }, [createFormMachineType, createFormDriverOptions, machineTypes]); - const [machineDetail, setMachineDetail] = useState(null); - const goBack = useCallback(() => setMachineDetail(null), []); + const [currentMachinePk, setCurrentMachinePk] = useState(null); + const goBack = useCallback(() => setCurrentMachinePk(null), []); - if (machineDetail) { - return ; + if (currentMachinePk) { + return ; } return ( @@ -149,19 +456,43 @@ export function MachineListTable({ props }: { props: InvenTreeTableProps }) { props={{ ...props, enableDownload: false, - onRowClick: (record) => setMachineDetail(record), + onRowClick: (record) => setCurrentMachinePk(record.pk), + customActionGroups: [ + { + setCreateFormMachineType(null); + openCreateApiForm(createMachineForm); + }} + /> + ], params: { ...props.params }, - rowActions: rowActions, customFilters: [ { name: 'active', label: t`Active`, type: 'boolean' + }, + { + name: 'machine_type', + label: t`Machine Type`, + type: 'choice', + choiceFunction: () => + machineTypes + ? machineTypes.map((t) => ({ value: t.slug, label: t.name })) + : [] + }, + { + name: 'driver', + label: t`Machine Driver`, + type: 'choice', + choiceFunction: () => + machineDrivers + ? machineDrivers.map((d) => ({ value: d.slug, label: d.name })) + : [] } - // TODO: add machine_type choices filter - // TODO: add driver choices filter ] }} /> From 44d277df6393c65a0bc0adabdc8bc56c935dfbc1 Mon Sep 17 00:00:00 2001 From: wolflu05 <76838159+wolflu05@users.noreply.github.com> Date: Fri, 26 Jan 2024 15:23:31 +0000 Subject: [PATCH 37/86] Fix merge issues --- InvenTree/InvenTree/helpers.py | 2 +- InvenTree/generic/states/api.py | 4 +-- InvenTree/machine/api.py | 16 ++++++------ .../tables/machine/MachineListTable.tsx | 25 ++++++++++--------- src/frontend/src/enums/ApiEndpoints.tsx | 10 +++++++- .../Index/Settings/AdminCenter/Index.tsx | 13 +++++++++- .../AdminCenter/MachineManagementPanel.tsx | 11 ++++++++ src/frontend/src/states/SettingsState.tsx | 7 ++++++ 8 files changed, 63 insertions(+), 25 deletions(-) create mode 100644 src/frontend/src/pages/Index/Settings/AdminCenter/MachineManagementPanel.tsx diff --git a/InvenTree/InvenTree/helpers.py b/InvenTree/InvenTree/helpers.py index 9a9e4281976..2d38efa376d 100644 --- a/InvenTree/InvenTree/helpers.py +++ b/InvenTree/InvenTree/helpers.py @@ -841,7 +841,7 @@ def get_target(self, obj): return {'name': str(item), 'model': str(model_cls._meta.verbose_name), **ret} -Inheritors_T = TypeVar("Inheritors_T") +Inheritors_T = TypeVar('Inheritors_T') def inheritors(cls: Type[Inheritors_T]) -> Set[Type[Inheritors_T]]: diff --git a/InvenTree/generic/states/api.py b/InvenTree/generic/states/api.py index 683bb79ae80..e71261ebb73 100644 --- a/InvenTree/generic/states/api.py +++ b/InvenTree/generic/states/api.py @@ -58,10 +58,10 @@ def get(self, request, *args, **kwargs): """Perform a GET request to learn information about status codes.""" data = {} - def discover_status_codes(parent_status_class, prefix=[]): + def discover_status_codes(parent_status_class, prefix=None): """Recursively discover status classes.""" for status_class in parent_status_class.__subclasses__(): - name = "__".join([*prefix, status_class.__name__]) + name = '__'.join([*(prefix or []), status_class.__name__]) data[name] = { 'class': status_class.__name__, 'values': status_class.dict(), diff --git a/InvenTree/machine/api.py b/InvenTree/machine/api.py index 66ada4378a5..8f2f7e80ab2 100644 --- a/InvenTree/machine/api.py +++ b/InvenTree/machine/api.py @@ -193,24 +193,24 @@ def get(self, request): machine_api_urls = [ # machine types - re_path(r"^types/", MachineTypesList.as_view(), name="api-machine-types"), + path("types/", MachineTypesList.as_view(), name="api-machine-types"), # machine drivers - re_path(r"^drivers/", MachineDriverList.as_view(), name="api-machine-drivers"), + path("drivers/", MachineDriverList.as_view(), name="api-machine-drivers"), # registry status - re_path(r"^status/", RegistryStatusView.as_view(), name="api-machine-registry-status"), + path("status/", RegistryStatusView.as_view(), name="api-machine-registry-status"), # detail views for a single Machine - path(r"/", include([ - re_path(r"^settings/", include([ + path("/", include([ + path("settings/", include([ re_path(r"^(?PM|D)/(?P\w+)/", MachineSettingDetail.as_view(), name="api-machine-settings-detail"), - re_path(r"^.*$", MachineSettingList.as_view(), name="api-machine-settings"), + path("", MachineSettingList.as_view(), name="api-machine-settings"), ])), - re_path(r"^.*$", MachineDetail.as_view(), name="api-machine-detail"), + path("", MachineDetail.as_view(), name="api-machine-detail"), ])), # machine list and create - re_path(r"^.*$", MachineList.as_view(), name="api-machine-list"), + path("", MachineList.as_view(), name="api-machine-list"), ] diff --git a/src/frontend/src/components/tables/machine/MachineListTable.tsx b/src/frontend/src/components/tables/machine/MachineListTable.tsx index 9323ab094e3..bcb450ded50 100644 --- a/src/frontend/src/components/tables/machine/MachineListTable.tsx +++ b/src/frontend/src/components/tables/machine/MachineListTable.tsx @@ -32,13 +32,13 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { api } from '../../../App'; import { + OpenApiFormProps, openCreateApiForm, openDeleteApiForm, openEditApiForm } from '../../../functions/forms'; import { notYetImplemented } from '../../../functions/notifications'; -import { useTableRefresh } from '../../../hooks/TableRefresh'; -import { ApiPaths, apiUrl } from '../../../states/ApiState'; +import { apiUrl } from '../../../states/ApiState'; import { AddItemButton } from '../../buttons/AddItemButton'; import { ButtonMenu } from '../../buttons/ButtonMenu'; import { ApiFormProps } from '../../forms/ApiForm'; @@ -52,12 +52,13 @@ import { YesNoButton } from '../../items/YesNoButton'; import { StatusRenderer, TableStatusRenderer -} from '../../renderers/StatusRenderer'; +} from '../../render/StatusRenderer'; import { MachineSettingList } from '../../settings/SettingList'; import { TableColumn } from '../Column'; import { BooleanColumn } from '../ColumnRenderers'; import { InvenTreeTable, InvenTreeTableProps } from '../InvenTreeTable'; -import { RowAction } from '../RowActions'; +import { ApiPaths } from "../../../enums/ApiEndpoints"; +import { useTable } from "../../../hooks/UseTable"; interface MachineI { pk: string; @@ -327,7 +328,7 @@ export function MachineListTable({ props }: { props: InvenTreeTableProps }) { staleTime: 10 * 1000 }); - const { tableKey, refreshTable } = useTableRefresh('machine'); + const table = useTable('machine'); const machineTableColumns = useMemo[]>( () => [ @@ -412,7 +413,7 @@ export function MachineListTable({ props }: { props: InvenTreeTableProps }) { })); }, [machineDrivers, createFormMachineType]); - const createMachineForm = useMemo(() => { + const createMachineForm = useMemo(() => { return { title: t`Create machine`, url: ApiPaths.machine_list, @@ -422,9 +423,9 @@ export function MachineListTable({ props }: { props: InvenTreeTableProps }) { field_type: 'choice', choices: machineTypes ? machineTypes.map((t) => ({ - value: t.slug, - display_name: `${t.name} (${t.description})` - })) + value: t.slug, + display_name: `${t.name} (${t.description})` + })) : [], onValueChange: ({ value }) => setCreateFormMachineType(value) }, @@ -451,13 +452,13 @@ export function MachineListTable({ props }: { props: InvenTreeTableProps }) { return ( setCurrentMachinePk(record.pk), - customActionGroups: [ + tableActions: [ { @@ -469,7 +470,7 @@ export function MachineListTable({ props }: { props: InvenTreeTableProps }) { params: { ...props.params }, - customFilters: [ + tableFilters: [ { name: 'active', label: t`Active`, diff --git a/src/frontend/src/enums/ApiEndpoints.tsx b/src/frontend/src/enums/ApiEndpoints.tsx index 0dc5b5368c5..6f8ce50a8ea 100644 --- a/src/frontend/src/enums/ApiEndpoints.tsx +++ b/src/frontend/src/enums/ApiEndpoints.tsx @@ -100,5 +100,13 @@ export enum ApiPaths { error_report_list = 'api-error-report-list', project_code_list = 'api-project-code-list', - custom_unit_list = 'api-custom-unit-list' + custom_unit_list = 'api-custom-unit-list', + + // Machine URLs + machine_types_list = 'api-machine-types', + machine_driver_list = 'api-machine-drivers', + machine_registry_status = 'api-machine-registry-status', + machine_list = 'api-machine-list', + machine_setting_list = 'api-machine-settings', + machine_setting_detail = 'api-machine-settings-detail', } diff --git a/src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx b/src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx index 3aa646ee229..93eb9fc79c0 100644 --- a/src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx +++ b/src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx @@ -7,7 +7,8 @@ import { IconListDetails, IconPlugConnected, IconScale, - IconUsersGroup + IconUsersGroup, + IconDevicesPc, } from '@tabler/icons-react'; import { lazy, useMemo } from 'react'; @@ -29,6 +30,10 @@ const PluginManagementPanel = Loadable( lazy(() => import('./PluginManagementPanel')) ); +const MachineManagementPanel = Loadable( + lazy(() => import("./MachineManagementPanel")) +); + const ErrorReportTable = Loadable( lazy(() => import('../../../../components/tables/settings/ErrorTable')) ); @@ -98,6 +103,12 @@ export default function AdminCenter() { label: t`Plugins`, icon: , content: + }, + { + name: 'machine', + label: t`Machines`, + icon: , + content: } ]; }, []); diff --git a/src/frontend/src/pages/Index/Settings/AdminCenter/MachineManagementPanel.tsx b/src/frontend/src/pages/Index/Settings/AdminCenter/MachineManagementPanel.tsx new file mode 100644 index 00000000000..4f39bebe435 --- /dev/null +++ b/src/frontend/src/pages/Index/Settings/AdminCenter/MachineManagementPanel.tsx @@ -0,0 +1,11 @@ +import { Stack } from '@mantine/core'; + +import { MachineListTable } from "../../../../components/tables/machine/MachineListTable"; + +export default function MachineManagementPanel() { + return ( + + + + ); +} diff --git a/src/frontend/src/states/SettingsState.tsx b/src/frontend/src/states/SettingsState.tsx index f301bcd8ed7..63d375c8668 100644 --- a/src/frontend/src/states/SettingsState.tsx +++ b/src/frontend/src/states/SettingsState.tsx @@ -160,6 +160,13 @@ export const createMachineSettingsState = ({ error ); }); + }, + getSetting: (key: string, default_value?: string) => { + return get().lookup[key] ?? default_value ?? ''; + }, + isSet: (key: string, default_value?: boolean) => { + let value = get().lookup[key] ?? default_value ?? 'false'; + return isTrue(value); } })); }; From a1602e422126fac053097761e338cabdfda7893e Mon Sep 17 00:00:00 2001 From: wolflu05 <76838159+wolflu05@users.noreply.github.com> Date: Fri, 26 Jan 2024 15:27:32 +0000 Subject: [PATCH 38/86] Fix style issues --- InvenTree/machine/api.py | 112 ++++++++++-------- .../tables/machine/MachineListTable.tsx | 27 ++--- src/frontend/src/enums/ApiEndpoints.tsx | 2 +- .../Index/Settings/AdminCenter/Index.tsx | 6 +- .../AdminCenter/MachineManagementPanel.tsx | 2 +- 5 files changed, 73 insertions(+), 76 deletions(-) diff --git a/InvenTree/machine/api.py b/InvenTree/machine/api.py index 8f2f7e80ab2..bb1c7c41151 100644 --- a/InvenTree/machine/api.py +++ b/InvenTree/machine/api.py @@ -8,8 +8,7 @@ import machine.serializers as MachineSerializers from InvenTree.filters import SEARCH_ORDER_FILTER -from InvenTree.mixins import (ListCreateAPI, RetrieveUpdateAPI, - RetrieveUpdateDestroyAPI) +from InvenTree.mixins import ListCreateAPI, RetrieveUpdateAPI, RetrieveUpdateDestroyAPI from machine import registry from machine.models import MachineConfig, MachineSetting @@ -26,33 +25,19 @@ class MachineList(ListCreateAPI): def get_serializer_class(self): # allow driver, machine_type fields on creation - if self.request.method == "POST": + if self.request.method == 'POST': return MachineSerializers.MachineConfigCreateSerializer return super().get_serializer_class() filter_backends = SEARCH_ORDER_FILTER - filterset_fields = [ - "machine_type", - "driver", - "active", - ] + filterset_fields = ['machine_type', 'driver', 'active'] - ordering_fields = [ - "name", - "machine_type", - "driver", - "active", - ] + ordering_fields = ['name', 'machine_type', 'driver', 'active'] - ordering = [ - "-active", - "machine_type", - ] + ordering = ['-active', 'machine_type'] - search_fields = [ - "name" - ] + search_fields = ['name'] class MachineDetail(RetrieveUpdateDestroyAPI): @@ -96,17 +81,25 @@ class MachineSettingList(APIView): permission_classes = [permissions.IsAuthenticated] - @extend_schema(responses={200: MachineSerializers.MachineSettingSerializer(many=True)}) + @extend_schema( + responses={200: MachineSerializers.MachineSettingSerializer(many=True)} + ) def get(self, request, pk): machine = get_machine(pk) all_settings = [] for settings, config_type in machine.setting_types: - settings_dict = MachineSetting.all_settings(settings_definition=settings, machine_config=machine.machine_config, config_type=config_type) + settings_dict = MachineSetting.all_settings( + settings_definition=settings, + machine_config=machine.machine_config, + config_type=config_type, + ) all_settings.extend(list(settings_dict.values())) - results = MachineSerializers.MachineSettingSerializer(all_settings, many=True).data + results = MachineSerializers.MachineSettingSerializer( + all_settings, many=True + ).data return Response(results) @@ -126,17 +119,21 @@ class MachineSettingDetail(RetrieveUpdateAPI): def get_object(self): """Lookup machine setting object, based on the URL.""" - pk = self.kwargs["pk"] - key = self.kwargs["key"] - config_type = MachineSetting.get_config_type(self.kwargs["config_type"]) + pk = self.kwargs['pk'] + key = self.kwargs['key'] + config_type = MachineSetting.get_config_type(self.kwargs['config_type']) machine = get_machine(pk) setting_map = dict((d, s) for s, d in machine.setting_types) if key.upper() not in setting_map[config_type]: - raise NotFound(detail=f"Machine '{machine.name}' has no {config_type.name} setting matching '{key.upper()}'") + raise NotFound( + detail=f"Machine '{machine.name}' has no {config_type.name} setting matching '{key.upper()}'" + ) - return MachineSetting.get_setting_object(key, machine_config=machine.machine_config, config_type=config_type) + return MachineSetting.get_setting_object( + key, machine_config=machine.machine_config, config_type=config_type + ) class MachineTypesList(APIView): @@ -150,7 +147,9 @@ class MachineTypesList(APIView): @extend_schema(responses={200: MachineSerializers.MachineTypeSerializer(many=True)}) def get(self, request): machine_types = list(registry.machine_types.values()) - results = MachineSerializers.MachineTypeSerializer(machine_types, many=True).data + results = MachineSerializers.MachineTypeSerializer( + machine_types, many=True + ).data return Response(results) @@ -162,13 +161,17 @@ class MachineDriverList(APIView): permission_classes = [permissions.IsAuthenticated] - @extend_schema(responses={200: MachineSerializers.MachineDriverSerializer(many=True)}) + @extend_schema( + responses={200: MachineSerializers.MachineDriverSerializer(many=True)} + ) def get(self, request): drivers = registry.drivers.values() - if machine_type := request.query_params.get("machine_type", None): + if machine_type := request.query_params.get('machine_type', None): drivers = filter(lambda d: d.machine_type == machine_type, drivers) - results = MachineSerializers.MachineDriverSerializer(list(drivers), many=True).data + results = MachineSerializers.MachineDriverSerializer( + list(drivers), many=True + ).data return Response(results) @@ -182,10 +185,12 @@ class RegistryStatusView(APIView): serializer_class = MachineSerializers.MachineRegistryStatusSerializer - @extend_schema(responses={200: MachineSerializers.MachineRegistryStatusSerializer()}) + @extend_schema( + responses={200: MachineSerializers.MachineRegistryStatusSerializer()} + ) def get(self, request): result = MachineSerializers.MachineRegistryStatusSerializer({ - "registry_errors": list(map(str, registry.errors)) + 'registry_errors': list(map(str, registry.errors)) }).data return Response(result) @@ -193,24 +198,29 @@ def get(self, request): machine_api_urls = [ # machine types - path("types/", MachineTypesList.as_view(), name="api-machine-types"), - + path('types/', MachineTypesList.as_view(), name='api-machine-types'), # machine drivers - path("drivers/", MachineDriverList.as_view(), name="api-machine-drivers"), - + path('drivers/', MachineDriverList.as_view(), name='api-machine-drivers'), # registry status - path("status/", RegistryStatusView.as_view(), name="api-machine-registry-status"), - + path('status/', RegistryStatusView.as_view(), name='api-machine-registry-status'), # detail views for a single Machine - path("/", include([ - path("settings/", include([ - re_path(r"^(?PM|D)/(?P\w+)/", MachineSettingDetail.as_view(), name="api-machine-settings-detail"), - path("", MachineSettingList.as_view(), name="api-machine-settings"), - ])), - - path("", MachineDetail.as_view(), name="api-machine-detail"), - ])), - + path( + '/', + include([ + path( + 'settings/', + include([ + re_path( + r'^(?PM|D)/(?P\w+)/', + MachineSettingDetail.as_view(), + name='api-machine-settings-detail', + ), + path('', MachineSettingList.as_view(), name='api-machine-settings'), + ]), + ), + path('', MachineDetail.as_view(), name='api-machine-detail'), + ]), + ), # machine list and create - path("", MachineList.as_view(), name="api-machine-list"), + path('', MachineList.as_view(), name='api-machine-list'), ] diff --git a/src/frontend/src/components/tables/machine/MachineListTable.tsx b/src/frontend/src/components/tables/machine/MachineListTable.tsx index bcb450ded50..d0bfa310e89 100644 --- a/src/frontend/src/components/tables/machine/MachineListTable.tsx +++ b/src/frontend/src/components/tables/machine/MachineListTable.tsx @@ -3,12 +3,8 @@ import { ActionIcon, Badge, Box, - Button, Card, - CheckIcon, Code, - Container, - Divider, Flex, Group, Indicator, @@ -20,28 +16,21 @@ import { Title, Tooltip } from '@mantine/core'; -import { - IconAlertCircle, - IconChevronLeft, - IconDots, - IconPlus, - IconRefresh -} from '@tabler/icons-react'; +import { IconChevronLeft, IconDots, IconRefresh } from '@tabler/icons-react'; import { useQuery } from '@tanstack/react-query'; -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; import { api } from '../../../App'; +import { ApiPaths } from '../../../enums/ApiEndpoints'; import { OpenApiFormProps, openCreateApiForm, openDeleteApiForm, openEditApiForm } from '../../../functions/forms'; -import { notYetImplemented } from '../../../functions/notifications'; +import { useTable } from '../../../hooks/UseTable'; import { apiUrl } from '../../../states/ApiState'; import { AddItemButton } from '../../buttons/AddItemButton'; -import { ButtonMenu } from '../../buttons/ButtonMenu'; -import { ApiFormProps } from '../../forms/ApiForm'; import { ActionDropdown, DeleteItemAction, @@ -57,8 +46,6 @@ import { MachineSettingList } from '../../settings/SettingList'; import { TableColumn } from '../Column'; import { BooleanColumn } from '../ColumnRenderers'; import { InvenTreeTable, InvenTreeTableProps } from '../InvenTreeTable'; -import { ApiPaths } from "../../../enums/ApiEndpoints"; -import { useTable } from "../../../hooks/UseTable"; interface MachineI { pk: string; @@ -423,9 +410,9 @@ export function MachineListTable({ props }: { props: InvenTreeTableProps }) { field_type: 'choice', choices: machineTypes ? machineTypes.map((t) => ({ - value: t.slug, - display_name: `${t.name} (${t.description})` - })) + value: t.slug, + display_name: `${t.name} (${t.description})` + })) : [], onValueChange: ({ value }) => setCreateFormMachineType(value) }, diff --git a/src/frontend/src/enums/ApiEndpoints.tsx b/src/frontend/src/enums/ApiEndpoints.tsx index 6f8ce50a8ea..03177aaff32 100644 --- a/src/frontend/src/enums/ApiEndpoints.tsx +++ b/src/frontend/src/enums/ApiEndpoints.tsx @@ -108,5 +108,5 @@ export enum ApiPaths { machine_registry_status = 'api-machine-registry-status', machine_list = 'api-machine-list', machine_setting_list = 'api-machine-settings', - machine_setting_detail = 'api-machine-settings-detail', + machine_setting_detail = 'api-machine-settings-detail' } diff --git a/src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx b/src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx index 93eb9fc79c0..22b98afd7ed 100644 --- a/src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx +++ b/src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx @@ -2,13 +2,13 @@ import { Trans, t } from '@lingui/macro'; import { Divider, Paper, SimpleGrid, Stack, Text, Title } from '@mantine/core'; import { IconCpu, + IconDevicesPc, IconExclamationCircle, IconList, IconListDetails, IconPlugConnected, IconScale, - IconUsersGroup, - IconDevicesPc, + IconUsersGroup } from '@tabler/icons-react'; import { lazy, useMemo } from 'react'; @@ -31,7 +31,7 @@ const PluginManagementPanel = Loadable( ); const MachineManagementPanel = Loadable( - lazy(() => import("./MachineManagementPanel")) + lazy(() => import('./MachineManagementPanel')) ); const ErrorReportTable = Loadable( diff --git a/src/frontend/src/pages/Index/Settings/AdminCenter/MachineManagementPanel.tsx b/src/frontend/src/pages/Index/Settings/AdminCenter/MachineManagementPanel.tsx index 4f39bebe435..892588dba1f 100644 --- a/src/frontend/src/pages/Index/Settings/AdminCenter/MachineManagementPanel.tsx +++ b/src/frontend/src/pages/Index/Settings/AdminCenter/MachineManagementPanel.tsx @@ -1,6 +1,6 @@ import { Stack } from '@mantine/core'; -import { MachineListTable } from "../../../../components/tables/machine/MachineListTable"; +import { MachineListTable } from '../../../../components/tables/machine/MachineListTable'; export default function MachineManagementPanel() { return ( From 2430af87e1922361fe58b077e2c11edf2d06511c Mon Sep 17 00:00:00 2001 From: wolflu05 <76838159+wolflu05@users.noreply.github.com> Date: Fri, 26 Jan 2024 22:47:49 +0000 Subject: [PATCH 39/86] Added machine type,machine driver,error stack tables --- InvenTree/machine/api.py | 4 +- InvenTree/machine/serializers.py | 17 +- .../components/forms/fields/ChoiceField.tsx | 36 +- .../src/components/items/InfoItem.tsx | 39 +- .../src/components/nav/DetailDrawer.tsx | 2 +- .../src/components/settings/SettingList.tsx | 6 + .../tables/machine/MachineListTable.tsx | 386 ++++++++++-------- .../tables/machine/MachineTypeTable.tsx | 321 +++++++++++++++ .../AdminCenter/MachineManagementPanel.tsx | 67 ++- 9 files changed, 671 insertions(+), 207 deletions(-) create mode 100644 src/frontend/src/components/tables/machine/MachineTypeTable.tsx diff --git a/InvenTree/machine/api.py b/InvenTree/machine/api.py index bb1c7c41151..72c2e9c3559 100644 --- a/InvenTree/machine/api.py +++ b/InvenTree/machine/api.py @@ -190,7 +190,9 @@ class RegistryStatusView(APIView): ) def get(self, request): result = MachineSerializers.MachineRegistryStatusSerializer({ - 'registry_errors': list(map(str, registry.errors)) + 'registry_errors': list( + {'message': str(error)} for error in registry.errors + ) }).data return Response(result) diff --git a/InvenTree/machine/serializers.py b/InvenTree/machine/serializers.py index 40d8c1704bf..fc22f6d8152 100644 --- a/InvenTree/machine/serializers.py +++ b/InvenTree/machine/serializers.py @@ -117,10 +117,10 @@ class Meta: def get_provider_file(self, obj: ClassProviderMixin) -> str: return obj.get_provider_file() - def get_provider_plugin(self, obj: ClassProviderMixin) -> Union[str, None]: + def get_provider_plugin(self, obj: ClassProviderMixin) -> Union[dict, None]: plugin = obj.get_provider_plugin() if plugin: - return plugin.slug + return {"slug": plugin.slug, "name": plugin.human_name, "pk": getattr(plugin.plugin_config(), "pk", None)} return None def get_is_builtin(self, obj: ClassProviderMixin) -> bool: @@ -150,6 +150,17 @@ class Meta(BaseMachineClassSerializer.Meta): machine_type = serializers.SlugField(read_only=True) +class MachineRegistryErrorSerializer(serializers.Serializer): + """Serializer for a machine registry error.""" + + class Meta: + fields = [ + "message" + ] + + message = serializers.CharField() + + class MachineRegistryStatusSerializer(serializers.Serializer): """Serializer for machine registry status.""" @@ -158,4 +169,4 @@ class Meta: "registry_errors", ] - registry_errors = serializers.ListField(child=serializers.CharField()) + registry_errors = serializers.ListField(child=MachineRegistryErrorSerializer()) diff --git a/src/frontend/src/components/forms/fields/ChoiceField.tsx b/src/frontend/src/components/forms/fields/ChoiceField.tsx index 6c5762bd32a..63e1f3ef4ea 100644 --- a/src/frontend/src/components/forms/fields/ChoiceField.tsx +++ b/src/frontend/src/components/forms/fields/ChoiceField.tsx @@ -1,4 +1,4 @@ -import { Select } from '@mantine/core'; +import { Input, Select } from '@mantine/core'; import { useId } from '@mantine/hooks'; import { useCallback } from 'react'; import { useMemo } from 'react'; @@ -49,16 +49,30 @@ export function ChoiceField({ [field.onChange, definition] ); + /* Construct a "cut-down" version of the definition, + * which does not include any attributes that the lower components do not recognize + */ + const fieldDefinition = useMemo(() => { + return { + ...definition, + onValueChange: undefined, + adjustFilters: undefined, + read_only: undefined + }; + }, [definition]); + return ( - + ); } diff --git a/src/frontend/src/components/items/InfoItem.tsx b/src/frontend/src/components/items/InfoItem.tsx index e77a302bb98..aeb5c5110f8 100644 --- a/src/frontend/src/components/items/InfoItem.tsx +++ b/src/frontend/src/components/items/InfoItem.tsx @@ -1,5 +1,6 @@ import { Trans } from '@lingui/macro'; -import { Flex, Group, Text } from '@mantine/core'; +import { Code, Flex, Group, Text } from '@mantine/core'; +import { Link, To } from 'react-router-dom'; import { YesNoButton } from './YesNoButton'; @@ -7,13 +8,37 @@ export function InfoItem({ name, children, type, - value + value, + link }: { name: string; children?: React.ReactNode; - type?: 'text' | 'boolean'; + type?: 'text' | 'boolean' | 'code'; value?: any; + link?: To; }) { + function renderComponent() { + if (value === undefined) return null; + + if (type === 'text') { + return {value || None}; + } + + if (type === 'boolean') { + return ; + } + + if (type === 'code') { + return ( + + {value} + + ); + } + + return null; + } + return ( @@ -21,13 +46,7 @@ export function InfoItem({ {children} - {value !== undefined && type === 'text' ? ( - {value || None} - ) : type === 'boolean' ? ( - - ) : ( - '' - )} + {link ? {renderComponent()} : renderComponent()} ); diff --git a/src/frontend/src/components/nav/DetailDrawer.tsx b/src/frontend/src/components/nav/DetailDrawer.tsx index fcb19180362..20ef06f21ca 100644 --- a/src/frontend/src/components/nav/DetailDrawer.tsx +++ b/src/frontend/src/components/nav/DetailDrawer.tsx @@ -31,7 +31,7 @@ function DetailDrawerComponent({ return ( navigate('../')} + onClose={() => navigate(-1)} position={position} size={size} title={ diff --git a/src/frontend/src/components/settings/SettingList.tsx b/src/frontend/src/components/settings/SettingList.tsx index 6f1ce65dd71..8e0f1c2dc98 100644 --- a/src/frontend/src/components/settings/SettingList.tsx +++ b/src/frontend/src/components/settings/SettingList.tsx @@ -1,3 +1,4 @@ +import { Trans } from '@lingui/macro'; import { Stack, Text } from '@mantine/core'; import React, { useEffect, useMemo, useRef } from 'react'; import { useStore } from 'zustand'; @@ -54,6 +55,11 @@ export function SettingList({ ); })} + {(keys || allKeys).length === 0 && ( + + No settings specified + + )} ); diff --git a/src/frontend/src/components/tables/machine/MachineListTable.tsx b/src/frontend/src/components/tables/machine/MachineListTable.tsx index d0bfa310e89..70262a3d0dd 100644 --- a/src/frontend/src/components/tables/machine/MachineListTable.tsx +++ b/src/frontend/src/components/tables/machine/MachineListTable.tsx @@ -13,21 +13,17 @@ import { Space, Stack, Text, - Title, - Tooltip + Title } from '@mantine/core'; -import { IconChevronLeft, IconDots, IconRefresh } from '@tabler/icons-react'; +import { IconDots, IconRefresh } from '@tabler/icons-react'; import { useQuery } from '@tanstack/react-query'; -import React, { useCallback, useMemo, useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; import { api } from '../../../App'; import { ApiPaths } from '../../../enums/ApiEndpoints'; -import { - OpenApiFormProps, - openCreateApiForm, - openDeleteApiForm, - openEditApiForm -} from '../../../functions/forms'; +import { openDeleteApiForm, openEditApiForm } from '../../../functions/forms'; +import { useCreateApiFormModal } from '../../../hooks/UseForm'; import { useTable } from '../../../hooks/UseTable'; import { apiUrl } from '../../../states/ApiState'; import { AddItemButton } from '../../buttons/AddItemButton'; @@ -36,8 +32,10 @@ import { DeleteItemAction, EditItemAction } from '../../items/ActionDropdown'; +import { InfoItem } from '../../items/InfoItem'; import { UnavailableIndicator } from '../../items/UnavailableIndicator'; import { YesNoButton } from '../../items/YesNoButton'; +import { DetailDrawer } from '../../nav/DetailDrawer'; import { StatusRenderer, TableStatusRenderer @@ -46,6 +44,7 @@ import { MachineSettingList } from '../../settings/SettingList'; import { TableColumn } from '../Column'; import { BooleanColumn } from '../ColumnRenderers'; import { InvenTreeTable, InvenTreeTableProps } from '../InvenTreeTable'; +import { MachineDriverI, MachineTypeI } from './MachineTypeTable'; interface MachineI { pk: string; @@ -61,25 +60,6 @@ interface MachineI { is_driver_available: boolean; } -interface MachineTypeI { - slug: string; - name: string; - description: string; - provider_file: string; - provider_plugin: string; - is_builtin: boolean; -} - -interface MachineDriverI { - slug: string; - name: string; - description: string; - provider_file: string; - provider_plugin: string; - is_builtin: boolean; - machine_type: string; -} - function MachineStatusIndicator({ machine }: { machine: MachineI }) { const sx = { marginLeft: '4px' }; @@ -111,13 +91,54 @@ function MachineStatusIndicator({ machine }: { machine: MachineI }) { ); } -function MachineDetail({ +export function useMachineTypeDriver({ + includeTypes = true, + includeDrivers = true +}: { includeTypes?: boolean; includeDrivers?: boolean } = {}) { + const { + data: machineTypes, + isFetching: isMachineTypesFetching, + refetch: refreshMachineTypes + } = useQuery({ + enabled: includeTypes, + queryKey: ['machine-types'], + queryFn: () => + api.get(apiUrl(ApiPaths.machine_types_list)).then((res) => res.data), + staleTime: 10 * 1000 + }); + const { + data: machineDrivers, + isFetching: isMachineDriversFetching, + refetch: refreshDrivers + } = useQuery({ + enabled: includeDrivers, + queryKey: ['machine-drivers'], + queryFn: () => + api.get(apiUrl(ApiPaths.machine_driver_list)).then((res) => res.data), + staleTime: 10 * 1000 + }); + + const refresh = useCallback(() => { + refreshMachineTypes(); + refreshDrivers(); + }, [refreshDrivers, refreshMachineTypes]); + + return { + machineTypes, + machineDrivers, + isFetching: isMachineTypesFetching || isMachineDriversFetching, + refresh + }; +} + +function MachineDrawer({ machinePk, - goBack + refreshTable }: { machinePk: string; - goBack: () => void; + refreshTable: () => void; }) { + const navigate = useNavigate(); const { data: machine, refetch, @@ -128,42 +149,34 @@ function MachineDetail({ queryFn: () => api.get(apiUrl(ApiPaths.machine_list, machinePk)).then((res) => res.data) }); - const { data: machineTypes, isFetching: isMachineTypesFetching } = useQuery< - MachineTypeI[] - >({ - queryKey: ['machine-types'], - queryFn: () => - api.get(apiUrl(ApiPaths.machine_types_list)).then((res) => res.data), - staleTime: 10 * 1000 - }); + const { + machineTypes, + machineDrivers, + isFetching: isMachineTypeDriverFetching + } = useMachineTypeDriver(); - const isFetching = isMachineFetching || isMachineTypesFetching; + const isFetching = isMachineFetching || isMachineTypeDriverFetching; - function InfoItem({ - name, - children - }: { - name: string; - children: React.ReactNode; - }) { - return ( - - - {name}: - - {children} - - ); - } + const machineType = useMemo( + () => + machineTypes && machine + ? machineTypes.find((t) => t.slug === machine.machine_type) + : undefined, + [machine?.machine_type, machineTypes] + ); + + const machineDriver = useMemo( + () => + machineDrivers && machine + ? machineDrivers.find((d) => d.slug === machine.driver) + : undefined, + [machine?.driver, machineDrivers] + ); return ( - - - - - + {machine && } @@ -200,7 +213,7 @@ function MachineDetail({ preFormContent: ( {t`Are you sure you want to remove the machine "${machine?.name}"?`} ), - onFormSuccess: () => goBack() + onFormSuccess: () => navigate(-1) }); } }) @@ -208,11 +221,11 @@ function MachineDetail({ /> - + - <Trans>Info</Trans> + <Trans>Machine information</Trans> refetch()}> @@ -222,17 +235,17 @@ function MachineDetail({ - {machine?.machine_type} - {machine && - machineTypes && - machineTypes.findIndex( - (m) => m.slug === machine.machine_type - ) === -1 && } + + {machineType ? machineType.name : machine?.machine_type} + + {machine && !machineType && } - {machine?.driver} + + {machineDriver ? machineDriver.name : machine?.driver} + {!machine?.is_driver_available && } @@ -269,8 +282,8 @@ function MachineDetail({ )} - {machine?.machine_errors.map((error) => ( - + {machine?.machine_errors.map((error, i) => ( + {error} ))} @@ -283,15 +296,19 @@ function MachineDetail({ {machine?.is_driver_available && ( <> - - <Trans>Machine Settings</Trans> - - + + + <Trans>Machine Settings</Trans> + + + - - <Trans>Driver Settings</Trans> - - + + + <Trans>Driver Settings</Trans> + + + )} @@ -302,20 +319,10 @@ function MachineDetail({ * Table displaying list of available plugins */ export function MachineListTable({ props }: { props: InvenTreeTableProps }) { - const { data: machineTypes } = useQuery({ - queryKey: ['machine-types'], - queryFn: () => - api.get(apiUrl(ApiPaths.machine_types_list)).then((res) => res.data), - staleTime: 10 * 1000 - }); - const { data: machineDrivers } = useQuery({ - queryKey: ['machine-drivers'], - queryFn: () => - api.get(apiUrl(ApiPaths.machine_driver_list)).then((res) => res.data), - staleTime: 10 * 1000 - }); + const { machineTypes, machineDrivers } = useMachineTypeDriver(); const table = useTable('machine'); + const navigate = useNavigate(); const machineTableColumns = useMemo[]>( () => [ @@ -337,13 +344,15 @@ export function MachineListTable({ props }: { props: InvenTreeTableProps }) { title: t`Machine Type`, sortable: true, render: (record) => { + const machineType = machineTypes?.find( + (m) => m.slug === record.machine_type + ); return ( - {record.machine_type} - {machineTypes && - machineTypes.findIndex( - (m: any) => m.slug === record.machine_type - ) === -1 && } + + {machineType ? machineType.name : record.machine_type} + + {machineTypes && !machineType && } ); } @@ -353,9 +362,10 @@ export function MachineListTable({ props }: { props: InvenTreeTableProps }) { title: t`Machine Driver`, sortable: true, render: (record) => { + const driver = machineDrivers?.find((d) => d.slug === record.driver); return ( - {record.driver} + {driver ? driver.name : record.driver} {!record.is_driver_available && } ); @@ -400,89 +410,107 @@ export function MachineListTable({ props }: { props: InvenTreeTableProps }) { })); }, [machineDrivers, createFormMachineType]); - const createMachineForm = useMemo(() => { - return { - title: t`Create machine`, - url: ApiPaths.machine_list, - fields: { - name: {}, - machine_type: { - field_type: 'choice', - choices: machineTypes - ? machineTypes.map((t) => ({ - value: t.slug, - display_name: `${t.name} (${t.description})` - })) - : [], - onValueChange: ({ value }) => setCreateFormMachineType(value) - }, - driver: { - field_type: 'choice', - disabled: !createFormMachineType, - choices: createFormDriverOptions - }, - active: {} + const createMachineForm = useCreateApiFormModal({ + title: t`Create machine`, + url: ApiPaths.machine_list, + fields: { + name: {}, + machine_type: { + field_type: 'choice', + choices: machineTypes + ? machineTypes.map((t) => ({ + value: t.slug, + display_name: `${t.name} (${t.description})` + })) + : [], + onValueChange: (value) => setCreateFormMachineType(value) }, - onClose: () => { - setCreateFormMachineType(null); - } - }; - }, [createFormMachineType, createFormDriverOptions, machineTypes]); - - const [currentMachinePk, setCurrentMachinePk] = useState(null); - const goBack = useCallback(() => setCurrentMachinePk(null), []); + driver: { + field_type: 'choice', + disabled: !createFormMachineType, + choices: createFormDriverOptions + }, + active: {} + }, + onFormSuccess: (data) => { + table.refreshTable(); + navigate(`machine-${data.pk}/`); + }, + onClose: () => { + setCreateFormMachineType(null); + } + }); - if (currentMachinePk) { - return ; - } + const tableActions = useMemo(() => { + return [ + { + setCreateFormMachineType(null); + createMachineForm.open(); + }} + /> + ]; + }, [createMachineForm.open]); return ( - setCurrentMachinePk(record.pk), - tableActions: [ - { - setCreateFormMachineType(null); - openCreateApiForm(createMachineForm); - }} - /> - ], - params: { - ...props.params - }, - tableFilters: [ - { - name: 'active', - label: t`Active`, - type: 'boolean' - }, - { - name: 'machine_type', - label: t`Machine Type`, - type: 'choice', - choiceFunction: () => - machineTypes - ? machineTypes.map((t) => ({ value: t.slug, label: t.name })) - : [] + <> + {createMachineForm.modal} + { + if (!id || !id.startsWith('machine-')) return false; + return ( + + ); + }} + /> + navigate(`machine-${machine.pk}/`), + tableActions, + params: { + ...props.params }, - { - name: 'driver', - label: t`Machine Driver`, - type: 'choice', - choiceFunction: () => - machineDrivers - ? machineDrivers.map((d) => ({ value: d.slug, label: d.name })) - : [] - } - ] - }} - /> + tableFilters: [ + { + name: 'active', + label: t`Active`, + type: 'boolean' + }, + { + name: 'machine_type', + label: t`Machine Type`, + type: 'choice', + choiceFunction: () => + machineTypes + ? machineTypes.map((t) => ({ value: t.slug, label: t.name })) + : [] + }, + { + name: 'driver', + label: t`Machine Driver`, + type: 'choice', + choiceFunction: () => + machineDrivers + ? machineDrivers.map((d) => ({ + value: d.slug, + label: d.name + })) + : [] + } + ] + }} + /> + ); } diff --git a/src/frontend/src/components/tables/machine/MachineTypeTable.tsx b/src/frontend/src/components/tables/machine/MachineTypeTable.tsx new file mode 100644 index 00000000000..dcb8aca6dc0 --- /dev/null +++ b/src/frontend/src/components/tables/machine/MachineTypeTable.tsx @@ -0,0 +1,321 @@ +import { Trans, t } from '@lingui/macro'; +import { + ActionIcon, + Card, + Group, + LoadingOverlay, + Stack, + Text, + Title +} from '@mantine/core'; +import { IconRefresh } from '@tabler/icons-react'; +import { useMemo } from 'react'; +import { useNavigate } from 'react-router-dom'; + +import { ApiPaths } from '../../../enums/ApiEndpoints'; +import { useTable } from '../../../hooks/UseTable'; +import { apiUrl } from '../../../states/ApiState'; +import { InfoItem } from '../../items/InfoItem'; +import { DetailDrawer } from '../../nav/DetailDrawer'; +import { TableColumn } from '../Column'; +import { BooleanColumn } from '../ColumnRenderers'; +import { InvenTreeTable, InvenTreeTableProps } from '../InvenTreeTable'; +import { useMachineTypeDriver } from './MachineListTable'; + +export interface MachineTypeI { + slug: string; + name: string; + description: string; + provider_file: string; + provider_plugin: { slug: string; name: string; pk: number | null } | null; + is_builtin: boolean; +} + +export interface MachineDriverI { + slug: string; + name: string; + description: string; + provider_file: string; + provider_plugin: { slug: string; name: string; pk: number | null } | null; + is_builtin: boolean; + machine_type: string; +} + +function MachineTypeDrawer({ machineTypeSlug }: { machineTypeSlug: string }) { + const navigate = useNavigate(); + + const { machineTypes, refresh, isFetching } = useMachineTypeDriver({ + includeDrivers: false + }); + const machineType = useMemo( + () => machineTypes?.find((m) => m.slug === machineTypeSlug), + [machineTypes, machineTypeSlug] + ); + + const table = useTable('machineDrivers'); + + const machineDriverTableColumns = useMemo[]>( + () => [ + { + accessor: 'name', + title: t`Name` + }, + { + accessor: 'slug', + title: t`Slug` + }, + { + accessor: 'description', + title: t`Description` + }, + BooleanColumn({ + accessor: 'is_builtin', + title: t`Builtin driver` + }) + ], + [] + ); + + return ( + + + + {machineType ? machineType.name : machineTypeSlug} + + + + {!machineType && ( + + Machine type not found. + + )} + + + + + + <Trans>Machine type information</Trans> + + refresh()}> + + + + + + + + + + + + + + + + + + + + <Trans>Available drivers</Trans> + + + { + return data.filter( + (d: any) => d.machine_type === machineTypeSlug + ); + }, + enableDownload: false, + enableSearch: false, + onRowClick: (machine) => navigate(`../driver-${machine.slug}/`) + }} + /> + + + + ); +} + +function MachineDriverDrawer({ + machineDriverSlug +}: { + machineDriverSlug: string; +}) { + const { machineDrivers, machineTypes, refresh, isFetching } = + useMachineTypeDriver(); + const machineDriver = useMemo( + () => machineDrivers?.find((d) => d.slug === machineDriverSlug), + [machineDrivers, machineDriverSlug] + ); + const machineType = useMemo( + () => machineTypes?.find((t) => t.slug === machineDriver?.machine_type), + [machineDrivers, machineTypes] + ); + + return ( + + + + {machineDriver ? machineDriver.name : machineDriverSlug} + + + + {!machineDriver && ( + + Machine driver not found. + + )} + + + + + + <Trans>Machine driver information</Trans> + + refresh()}> + + + + + + + + + + + + + + + + + + ); +} + +/** + * Table displaying list of available machine types + */ +export function MachineTypeListTable({ + props +}: { + props: InvenTreeTableProps; +}) { + const table = useTable('machineTypes'); + const navigate = useNavigate(); + + const machineTypeTableColumns = useMemo[]>( + () => [ + { + accessor: 'name', + title: t`Name` + }, + { + accessor: 'slug', + title: t`Slug` + }, + { + accessor: 'description', + title: t`Description` + }, + BooleanColumn({ + accessor: 'is_builtin', + title: t`Builtin type` + }) + ], + [] + ); + + return ( + <> + { + if (!id || !id.startsWith('type-')) return false; + return ( + + ); + }} + /> + { + if (!id || !id.startsWith('driver-')) return false; + return ( + + ); + }} + /> + navigate(`type-${machine.slug}/`), + params: { + ...props.params + } + }} + /> + + ); +} diff --git a/src/frontend/src/pages/Index/Settings/AdminCenter/MachineManagementPanel.tsx b/src/frontend/src/pages/Index/Settings/AdminCenter/MachineManagementPanel.tsx index 892588dba1f..58a1e393333 100644 --- a/src/frontend/src/pages/Index/Settings/AdminCenter/MachineManagementPanel.tsx +++ b/src/frontend/src/pages/Index/Settings/AdminCenter/MachineManagementPanel.tsx @@ -1,11 +1,74 @@ -import { Stack } from '@mantine/core'; +import { Trans } from '@lingui/macro'; +import { + ActionIcon, + Code, + Group, + List, + Space, + Stack, + Text, + Title +} from '@mantine/core'; +import { IconRefresh } from '@tabler/icons-react'; +import { useQuery } from '@tanstack/react-query'; +import { api } from '../../../../App'; import { MachineListTable } from '../../../../components/tables/machine/MachineListTable'; +import { MachineTypeListTable } from '../../../../components/tables/machine/MachineTypeTable'; +import { ApiPaths } from '../../../../enums/ApiEndpoints'; +import { apiUrl } from '../../../../states/ApiState'; + +interface MachineRegistryStatusI { + registry_errors: { message: string }[]; +} export default function MachineManagementPanel() { + const { data: registryStatus, refetch } = useQuery({ + queryKey: ['machine-registry-status'], + queryFn: () => + api.get(apiUrl(ApiPaths.machine_registry_status)).then((res) => res.data), + staleTime: 10 * 1000 + }); + return ( - + + + + + + + <Trans>Machine types</Trans> + + + + + + + + + + <Trans>Machine Error Stack</Trans> + + refetch()}> + + + + {registryStatus?.registry_errors && + registryStatus.registry_errors.length === 0 ? ( + + There are no machine registry errors. + + ) : ( + + {registryStatus?.registry_errors?.map((error, i) => ( + + {error.message} + + ))} + + )} + ); } From 758deb5b8a2940e5e959f72aa05c02ff6b7490ce Mon Sep 17 00:00:00 2001 From: wolflu05 <76838159+wolflu05@users.noreply.github.com> Date: Fri, 26 Jan 2024 22:48:27 +0000 Subject: [PATCH 40/86] Fix style in machine/serializers.py --- InvenTree/machine/serializers.py | 110 +++++++++++++++---------------- 1 file changed, 54 insertions(+), 56 deletions(-) diff --git a/InvenTree/machine/serializers.py b/InvenTree/machine/serializers.py index fc22f6d8152..52f3293504f 100644 --- a/InvenTree/machine/serializers.py +++ b/InvenTree/machine/serializers.py @@ -12,38 +12,36 @@ class MachineConfigSerializer(serializers.ModelSerializer): class Meta: """Meta for serializer.""" + model = MachineConfig fields = [ - "pk", - "name", - "machine_type", - "driver", - "initialized", - "active", - "status", - "status_model", - "status_text", - "machine_errors", - "is_driver_available", + 'pk', + 'name', + 'machine_type', + 'driver', + 'initialized', + 'active', + 'status', + 'status_model', + 'status_text', + 'machine_errors', + 'is_driver_available', ] - read_only_fields = [ - "machine_type", - "driver", - ] + read_only_fields = ['machine_type', 'driver'] - initialized = serializers.SerializerMethodField("get_initialized") - status = serializers.SerializerMethodField("get_status") - status_model = serializers.SerializerMethodField("get_status_model") - status_text = serializers.SerializerMethodField("get_status_text") - machine_errors = serializers.SerializerMethodField("get_errors") - is_driver_available = serializers.SerializerMethodField("get_is_driver_available") + initialized = serializers.SerializerMethodField('get_initialized') + status = serializers.SerializerMethodField('get_status') + status_model = serializers.SerializerMethodField('get_status_model') + status_text = serializers.SerializerMethodField('get_status_text') + machine_errors = serializers.SerializerMethodField('get_errors') + is_driver_available = serializers.SerializerMethodField('get_is_driver_available') def get_initialized(self, obj: MachineConfig) -> bool: - return getattr(obj.machine, "initialized", False) + return getattr(obj.machine, 'initialized', False) def get_status(self, obj: MachineConfig) -> int: - status = getattr(obj.machine, "status", None) + status = getattr(obj.machine, 'status', None) if status is not None: return status.value return -1 @@ -54,7 +52,7 @@ def get_status_model(self, obj: MachineConfig) -> Union[str, None]: return None def get_status_text(self, obj: MachineConfig) -> str: - return getattr(obj.machine, "status_text", "") + return getattr(obj.machine, 'status_text', '') def get_errors(self, obj: MachineConfig) -> List[str]: return [str(err) for err in obj.errors] @@ -68,27 +66,29 @@ class MachineConfigCreateSerializer(MachineConfigSerializer): class Meta(MachineConfigSerializer.Meta): """Meta for serializer.""" - read_only_fields = list(set(MachineConfigSerializer.Meta.read_only_fields) - set(["machine_type", "driver"])) + + read_only_fields = list( + set(MachineConfigSerializer.Meta.read_only_fields) + - set(['machine_type', 'driver']) + ) class MachineSettingSerializer(GenericReferencedSettingSerializer): """Serializer for the MachineSetting model.""" MODEL = MachineSetting - EXTRA_FIELDS = [ - "config_type", - ] + EXTRA_FIELDS = ['config_type'] def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) # remove unwanted fields - unwanted_fields = ["pk", "model_name", "api_url", "typ"] + unwanted_fields = ['pk', 'model_name', 'api_url', 'typ'] for f in unwanted_fields: if f in self.Meta.fields: self.Meta.fields.remove(f) - setattr(self.Meta, "read_only_fields", ["config_type"]) + self.Meta.read_only_fields = ['config_type'] class BaseMachineClassSerializer(serializers.Serializer): @@ -96,23 +96,24 @@ class BaseMachineClassSerializer(serializers.Serializer): class Meta: """Meta for a serializer.""" + fields = [ - "slug", - "name", - "description", - "provider_file", - "provider_plugin", - "is_builtin", + 'slug', + 'name', + 'description', + 'provider_file', + 'provider_plugin', + 'is_builtin', ] read_only_fields = fields - slug = serializers.SlugField(source="SLUG") - name = serializers.CharField(source="NAME") - description = serializers.CharField(source="DESCRIPTION") - provider_file = serializers.SerializerMethodField("get_provider_file") - provider_plugin = serializers.SerializerMethodField("get_provider_plugin") - is_builtin = serializers.SerializerMethodField("get_is_builtin") + slug = serializers.SlugField(source='SLUG') + name = serializers.CharField(source='NAME') + description = serializers.CharField(source='DESCRIPTION') + provider_file = serializers.SerializerMethodField('get_provider_file') + provider_plugin = serializers.SerializerMethodField('get_provider_plugin') + is_builtin = serializers.SerializerMethodField('get_is_builtin') def get_provider_file(self, obj: ClassProviderMixin) -> str: return obj.get_provider_file() @@ -120,7 +121,11 @@ def get_provider_file(self, obj: ClassProviderMixin) -> str: def get_provider_plugin(self, obj: ClassProviderMixin) -> Union[dict, None]: plugin = obj.get_provider_plugin() if plugin: - return {"slug": plugin.slug, "name": plugin.human_name, "pk": getattr(plugin.plugin_config(), "pk", None)} + return { + 'slug': plugin.slug, + 'name': plugin.human_name, + 'pk': getattr(plugin.plugin_config(), 'pk', None), + } return None def get_is_builtin(self, obj: ClassProviderMixin) -> bool: @@ -132,9 +137,8 @@ class MachineTypeSerializer(BaseMachineClassSerializer): class Meta(BaseMachineClassSerializer.Meta): """Meta for a serializer.""" - fields = [ - *BaseMachineClassSerializer.Meta.fields - ] + + fields = [*BaseMachineClassSerializer.Meta.fields] class MachineDriverSerializer(BaseMachineClassSerializer): @@ -142,10 +146,8 @@ class MachineDriverSerializer(BaseMachineClassSerializer): class Meta(BaseMachineClassSerializer.Meta): """Meta for a serializer.""" - fields = [ - *BaseMachineClassSerializer.Meta.fields, - "machine_type", - ] + + fields = [*BaseMachineClassSerializer.Meta.fields, 'machine_type'] machine_type = serializers.SlugField(read_only=True) @@ -154,9 +156,7 @@ class MachineRegistryErrorSerializer(serializers.Serializer): """Serializer for a machine registry error.""" class Meta: - fields = [ - "message" - ] + fields = ['message'] message = serializers.CharField() @@ -165,8 +165,6 @@ class MachineRegistryStatusSerializer(serializers.Serializer): """Serializer for machine registry status.""" class Meta: - fields = [ - "registry_errors", - ] + fields = ['registry_errors'] registry_errors = serializers.ListField(child=MachineRegistryErrorSerializer()) From c87a9f0aa331824952d306cf0f895cf7e8be330a Mon Sep 17 00:00:00 2001 From: wolflu05 <76838159+wolflu05@users.noreply.github.com> Date: Fri, 26 Jan 2024 23:04:57 +0000 Subject: [PATCH 41/86] Added pui link from machine to machine type/driver drawer --- .../tables/machine/MachineListTable.tsx | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/src/frontend/src/components/tables/machine/MachineListTable.tsx b/src/frontend/src/components/tables/machine/MachineListTable.tsx index 70262a3d0dd..dfb9586986f 100644 --- a/src/frontend/src/components/tables/machine/MachineListTable.tsx +++ b/src/frontend/src/components/tables/machine/MachineListTable.tsx @@ -19,6 +19,7 @@ import { IconDots, IconRefresh } from '@tabler/icons-react'; import { useQuery } from '@tanstack/react-query'; import { useCallback, useMemo, useState } from 'react'; import { useNavigate } from 'react-router-dom'; +import { Link } from 'react-router-dom'; import { api } from '../../../App'; import { ApiPaths } from '../../../enums/ApiEndpoints'; @@ -235,17 +236,25 @@ function MachineDrawer({ - - {machineType ? machineType.name : machine?.machine_type} - + {machineType ? ( + + {machineType.name} + + ) : ( + {machine?.machine_type} + )} {machine && !machineType && } - - {machineDriver ? machineDriver.name : machine?.driver} - + {machineDriver ? ( + + {machineDriver.name} + + ) : ( + {machine?.driver} + )} {!machine?.is_driver_available && } From 8a59c90c09966cb1c4358b2efa325c4833d68573 Mon Sep 17 00:00:00 2001 From: wolflu05 <76838159+wolflu05@users.noreply.github.com> Date: Sat, 27 Jan 2024 09:33:50 +0000 Subject: [PATCH 42/86] Removed only partially working django admin in favor of the PUI admin center implementation --- InvenTree/machine/admin.py | 74 +++++-------------- .../machine_types/LabelPrintingMachineType.py | 20 ++--- 2 files changed, 30 insertions(+), 64 deletions(-) diff --git a/InvenTree/machine/admin.py b/InvenTree/machine/admin.py index c23147348c3..5730415a517 100755 --- a/InvenTree/machine/admin.py +++ b/InvenTree/machine/admin.py @@ -1,25 +1,7 @@ -from django import forms from django.contrib import admin -from django.utils.translation import gettext_lazy as _ +from django.http.request import HttpRequest from machine import models -from machine.registry import registry - -# Note: Most of this code here is only for developing as there is no UI for machines *yet*. - - -class MachineConfigAdminForm(forms.ModelForm): - def get_machine_type_choices(): - return [(machine_type.SLUG, machine_type.NAME) for machine_type in registry.machine_types.values()] - - def get_driver_choices(): - return [(driver.SLUG, driver.NAME) for driver in registry.drivers.values()] - - # TODO: add conditional choices like shown here - # Ref: https://www.reddit.com/r/django/comments/18cj55/conditional_choices_for_model_field_based_on/ - # Ref: https://gist.github.com/blackrobot/4956070 - driver = forms.ChoiceField(label=_("Driver"), choices=get_driver_choices) - machine_type = forms.ChoiceField(label=_("Machine Type"), choices=get_machine_type_choices) class MachineSettingInline(admin.TabularInline): @@ -27,54 +9,38 @@ class MachineSettingInline(admin.TabularInline): model = models.MachineSetting - read_only_fields = [ - 'key', - 'config_type' - ] - - def get_extra(self, request, obj, **kwargs): - if getattr(obj, 'machine', None) is not None: - # TODO: improve this mechanism - machine_settings = getattr(obj.machine, "MACHINE_SETTINGS", {}) - driver_settings = getattr(obj.machine.driver, "MACHINE_SETTINGS", {}) - count = len(machine_settings.keys()) + len(driver_settings.keys()) - if obj.settings.count() != count: - return count - return 0 + read_only_fields = ['key', 'config_type'] def has_add_permission(self, request, obj): """The machine settings should not be meddled with manually.""" - return True # TODO: change back + return False @admin.register(models.MachineConfig) class MachineConfigAdmin(admin.ModelAdmin): """Custom admin with restricted id fields.""" - form = MachineConfigAdminForm - list_filter = ["active"] - list_display = ["name", "machine_type", "driver", "initialized", "active", "no_errors", "get_machine_status"] - readonly_fields = ["initialized", "is_driver_available", "get_admin_errors", "get_machine_status"] + list_filter = ['active'] + list_display = [ + 'name', + 'machine_type', + 'driver', + 'initialized', + 'active', + 'no_errors', + 'get_machine_status', + ] + readonly_fields = [ + 'initialized', + 'is_driver_available', + 'get_admin_errors', + 'get_machine_status', + ] inlines = [MachineSettingInline] def get_readonly_fields(self, request, obj): # if update, don't allow changes on machine_type and driver if obj is not None: - return ["machine_type", "driver", *self.readonly_fields] + return ['machine_type', 'driver', *self.readonly_fields] return self.readonly_fields - - def get_inline_formsets(self, request, formsets, inline_instances, obj): - formsets = super().get_inline_formsets(request, formsets, inline_instances, obj) - - if getattr(obj, 'machine', None) is not None: - machine_settings = getattr(obj.machine, "MACHINE_SETTINGS", {}) - driver_settings = getattr(obj.machine.driver, "MACHINE_SETTINGS", {}) - settings = [(s, models.MachineSetting.ConfigType.MACHINE) for s in machine_settings] + [(s, models.MachineSetting.ConfigType.DRIVER) for s in driver_settings] - for form, (setting, typ) in zip(formsets[0].forms, settings): - if form.fields["key"].initial is None: - form.fields["key"].initial = setting - if form.fields["config_type"].initial is None: - form.fields["config_type"].initial = typ - - return formsets diff --git a/InvenTree/machine/machine_types/LabelPrintingMachineType.py b/InvenTree/machine/machine_types/LabelPrintingMachineType.py index 20f47babe29..79c3108df44 100644 --- a/InvenTree/machine/machine_types/LabelPrintingMachineType.py +++ b/InvenTree/machine/machine_types/LabelPrintingMachineType.py @@ -6,31 +6,31 @@ class BaseLabelPrintingDriver(BaseDriver): """Base label printing driver.""" - machine_type = "label_printer" + machine_type = 'label_printer' def print_label(self): """This function must be overridden.""" - raise NotImplementedError("The `print_label` function must be overridden!") + raise NotImplementedError('The `print_label` function must be overridden!') def print_labels(self): """This function must be overridden.""" - raise NotImplementedError("The `print_labels` function must be overridden!") + raise NotImplementedError('The `print_labels` function must be overridden!') requires_override = [print_label] class LabelPrintingMachineType(BaseMachineType): - SLUG = "label_printer" - NAME = _("Label Printer") - DESCRIPTION = _("Device used to print labels") + SLUG = 'label_printer' + NAME = _('Label Printer') + DESCRIPTION = _('Directly print labels for various items.') base_driver = BaseLabelPrintingDriver class LabelPrinterStatus(MachineStatus): - CONNECTED = 100, _("Connected"), "success" - PRINTING = 101, _("Printing"), "primary" - PAPER_MISSING = 301, _("Paper missing"), "warning" - DISCONNECTED = 400, _("Disconnected"), "danger" + CONNECTED = 100, _('Connected'), 'success' + PRINTING = 101, _('Printing'), 'primary' + PAPER_MISSING = 301, _('Paper missing'), 'warning' + DISCONNECTED = 400, _('Disconnected'), 'danger' MACHINE_STATUS = LabelPrinterStatus From 1c227f8d235624f553d8e171b3bc40ec4529d1f5 Mon Sep 17 00:00:00 2001 From: wolflu05 <76838159+wolflu05@users.noreply.github.com> Date: Sat, 27 Jan 2024 10:32:16 +0000 Subject: [PATCH 43/86] Added required field to settings item --- InvenTree/common/serializers.py | 3 +++ src/frontend/src/components/settings/SettingItem.tsx | 5 ++++- src/frontend/src/states/states.tsx | 1 + 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/InvenTree/common/serializers.py b/InvenTree/common/serializers.py index 8b6dcb70c26..572b96d9974 100644 --- a/InvenTree/common/serializers.py +++ b/InvenTree/common/serializers.py @@ -59,6 +59,8 @@ class SettingsSerializer(InvenTreeModelSerializer): units = serializers.CharField(read_only=True) + required = serializers.BooleanField(read_only=True) + def get_choices(self, obj): """Returns the choices available for a given item.""" results = [] @@ -148,6 +150,7 @@ class CustomMeta: 'model_name', 'api_url', 'typ', + 'required', ] # set Meta class diff --git a/src/frontend/src/components/settings/SettingItem.tsx b/src/frontend/src/components/settings/SettingItem.tsx index 4fce3cc1458..28deffc3a10 100644 --- a/src/frontend/src/components/settings/SettingItem.tsx +++ b/src/frontend/src/components/settings/SettingItem.tsx @@ -174,7 +174,10 @@ export function SettingItem({ - {setting.name} + + {setting.name} + {setting.required ? ' *' : ''} + {setting.description} diff --git a/src/frontend/src/states/states.tsx b/src/frontend/src/states/states.tsx index 27db2fc39aa..2ac57b8291a 100644 --- a/src/frontend/src/states/states.tsx +++ b/src/frontend/src/states/states.tsx @@ -73,6 +73,7 @@ export interface Setting { typ: SettingTyp; plugin?: string; method?: string; + required?: boolean; } export interface SettingChoice { From 0e141c0103e057d4c265d0e1a1278c4e5329c782 Mon Sep 17 00:00:00 2001 From: wolflu05 <76838159+wolflu05@users.noreply.github.com> Date: Sat, 27 Jan 2024 15:13:55 +0000 Subject: [PATCH 44/86] Added machine restart function --- InvenTree/machine/api.py | 21 ++++ InvenTree/machine/machine_type.py | 117 ++++++++++++++---- .../machine_types/LabelPrintingMachineType.py | 5 +- InvenTree/machine/models.py | 87 +++++++------ InvenTree/machine/registry.py | 59 ++++++--- InvenTree/machine/serializers.py | 14 +++ .../src/components/items/ActionDropdown.tsx | 60 +++++---- .../src/components/settings/SettingItem.tsx | 16 ++- .../src/components/settings/SettingList.tsx | 11 +- .../tables/machine/MachineListTable.tsx | 49 +++++++- src/frontend/src/enums/ApiEndpoints.tsx | 1 + src/frontend/src/states/ApiState.tsx | 2 + 12 files changed, 327 insertions(+), 115 deletions(-) diff --git a/InvenTree/machine/api.py b/InvenTree/machine/api.py index 72c2e9c3559..d6842ffeae5 100644 --- a/InvenTree/machine/api.py +++ b/InvenTree/machine/api.py @@ -136,6 +136,23 @@ def get_object(self): ) +class MachineRestart(APIView): + """Endpoint for performing a machine restart. + + - POST: restart machine + """ + + permission_classes = [permissions.IsAuthenticated] + + @extend_schema(responses={200: MachineSerializers.MachineRestartSerializer()}) + def post(self, request, pk): + machine = get_machine(pk) + registry.restart_machine(machine) + + result = MachineSerializers.MachineRestartSerializer({'ok': True}).data + return Response(result) + + class MachineTypesList(APIView): """List API Endpoint for all discovered machine types. @@ -209,6 +226,7 @@ def get(self, request): path( '/', include([ + # settings path( 'settings/', include([ @@ -220,6 +238,9 @@ def get(self, request): path('', MachineSettingList.as_view(), name='api-machine-settings'), ]), ), + # restart + path('restart/', MachineRestart.as_view(), name='api-machine-restart'), + # detail path('', MachineDetail.as_view(), name='api-machine-detail'), ]), ), diff --git a/InvenTree/machine/machine_type.py b/InvenTree/machine/machine_type.py index 94d04c792aa..332b0141a98 100644 --- a/InvenTree/machine/machine_type.py +++ b/InvenTree/machine/machine_type.py @@ -8,6 +8,7 @@ from common.models import SettingsKeyType from machine.models import MachineConfig else: # pragma: no cover + class MachineConfig: pass @@ -34,6 +35,7 @@ class MachineStatus(StatusCode): 4XX - Something wrong with the driver (e.g. cannot connect to the machine) 5XX - Unknown issues """ + pass @@ -56,9 +58,9 @@ class BaseDriver(ClassValidationMixin, ClassProviderMixin): machine_type: str - required_attributes = ["SLUG", "NAME", "DESCRIPTION", "machine_type"] + required_attributes = ['SLUG', 'NAME', 'DESCRIPTION', 'machine_type'] - def init_machine(self, machine: "BaseMachineType"): + def init_machine(self, machine: 'BaseMachineType'): """This method gets called for each active machine using that driver while initialization. If this function raises an Exception, it gets added to the machine.errors @@ -69,11 +71,13 @@ def init_machine(self, machine: "BaseMachineType"): """ pass - def update_machine(self, old_machine_state: Dict[str, Any], machine: "BaseMachineType"): + def update_machine( + self, old_machine_state: Dict[str, Any], machine: 'BaseMachineType' + ): """This method gets called for each update of a machine - TODO: this function gets called even the settings are not stored yet when edited through the admin dashboard - TODO: test also if API is done, that this function gets called for settings changes + Note: + machine.restart_required can be set to True here if the machine needs a manual restart to apply the changes Arguments: old_machine_state: Dict holding the old machine state before update @@ -81,6 +85,17 @@ def update_machine(self, old_machine_state: Dict[str, Any], machine: "BaseMachin """ pass + def restart_machine(self, machine: 'BaseMachineType'): + """This method gets called on manual machine restart e.g. by using the restart machine action in the Admin Center. + + Note: + machine.restart_required gets set to False again + + Arguments: + machine: Machine instance + """ + pass + def get_machines(self, **kwargs): """Return all machines using this driver. (By default only initialized machines)""" from machine import registry @@ -116,7 +131,14 @@ class BaseMachineType(ClassValidationMixin, ClassProviderMixin): default_machine_status: MachineStatus # used by the ClassValidationMixin - required_attributes = ["SLUG", "NAME", "DESCRIPTION", "base_driver", "MACHINE_STATUS", "default_machine_status"] + required_attributes = [ + 'SLUG', + 'NAME', + 'DESCRIPTION', + 'base_driver', + 'MACHINE_STATUS', + 'default_machine_status', + ] def __init__(self, machine_config: MachineConfig) -> None: from machine import registry @@ -126,7 +148,7 @@ def __init__(self, machine_config: MachineConfig) -> None: self.initialized = False self.status = self.default_machine_status - self.status_text = "" + self.status_text = '' self.pk = machine_config.pk self.driver = registry.get_driver_instance(machine_config.driver) @@ -134,29 +156,40 @@ def __init__(self, machine_config: MachineConfig) -> None: if not self.driver: self.errors.append(f"Driver '{machine_config.driver}' not found") if self.driver and not isinstance(self.driver, self.base_driver): - self.errors.append(f"'{self.driver.NAME}' is incompatible with machine type '{self.NAME}'") - - self.machine_settings: Dict[str, SettingsKeyType] = getattr(self, "MACHINE_SETTINGS", {}) - self.driver_settings: Dict[str, SettingsKeyType] = getattr(self.driver, "MACHINE_SETTINGS", {}) - - self.setting_types: List[Tuple[Dict[str, SettingsKeyType], MachineSetting.ConfigType]] = [ + self.errors.append( + f"'{self.driver.NAME}' is incompatible with machine type '{self.NAME}'" + ) + + self.machine_settings: Dict[str, SettingsKeyType] = getattr( + self, 'MACHINE_SETTINGS', {} + ) + self.driver_settings: Dict[str, SettingsKeyType] = getattr( + self.driver, 'MACHINE_SETTINGS', {} + ) + + self.setting_types: List[ + Tuple[Dict[str, SettingsKeyType], MachineSetting.ConfigType] + ] = [ (self.machine_settings, MachineSetting.ConfigType.MACHINE), (self.driver_settings, MachineSetting.ConfigType.DRIVER), ] + self.restart_required = False + if len(self.errors) > 0: return # TODO: add further init stuff here def __str__(self): - return f"{self.name}" + return f'{self.name}' # --- properties @property def machine_config(self): # always fetch the machine_config if needed to ensure we get the newest reference from .models import MachineConfig + return MachineConfig.objects.get(pk=self.pk) @property @@ -179,7 +212,9 @@ def initialize(self): error_parts = [] for config_type, missing in missing_settings.items(): if len(missing) > 0: - error_parts.append(f"{config_type.name} settings: " + ", ".join(missing)) + error_parts.append( + f'{config_type.name} settings: ' + ', '.join(missing) + ) self.errors.append(f"Missing {' and '.join(error_parts)}") return @@ -189,8 +224,29 @@ def initialize(self): except Exception as e: self.errors.append(e) + def update(self, old_state: dict[str, Any]): + """Machine update function, gets called if the machine itself changes or their settings.""" + if self.driver is None: + return + + try: + self.driver.update_machine(old_state, self) + except Exception as e: + self.errors.append(e) + + def restart(self): + """Machine restart function, can be used to manually restart the machine from the admin ui.""" + if self.driver is None: + return + + try: + self.restart_required = False + self.driver.restart_machine(self) + except Exception as e: + self.errors.append(e) + # --- helper functions - def get_setting(self, key, config_type_str: Literal["M", "D"], cache=False): + def get_setting(self, key, config_type_str: Literal['M', 'D'], cache=False): """Return the 'value' of the setting associated with this machine. Arguments: @@ -201,9 +257,14 @@ def get_setting(self, key, config_type_str: Literal["M", "D"], cache=False): from machine.models import MachineSetting config_type = MachineSetting.get_config_type(config_type_str) - return MachineSetting.get_setting(key, machine_config=self.machine_config, config_type=config_type, cache=cache) - - def set_setting(self, key, config_type_str: Literal["M", "D"], value): + return MachineSetting.get_setting( + key, + machine_config=self.machine_config, + config_type=config_type, + cache=cache, + ) + + def set_setting(self, key, config_type_str: Literal['M', 'D'], value): """Set plugin setting value by key. Arguments: @@ -214,7 +275,13 @@ def set_setting(self, key, config_type_str: Literal["M", "D"], value): from machine.models import MachineSetting config_type = MachineSetting.get_config_type(config_type_str) - MachineSetting.set_setting(key, value, None, machine_config=self.machine_config, config_type=config_type) + MachineSetting.set_setting( + key, + value, + None, + machine_config=self.machine_config, + config_type=config_type, + ) def check_settings(self): """Check if all required settings for this machine are defined. @@ -227,10 +294,16 @@ def check_settings(self): missing_settings: Dict[MachineSetting.ConfigType, List[str]] = {} for settings, config_type in self.setting_types: - is_valid, missing = MachineSetting.check_all_settings(settings_definition=settings, machine_config=self.machine_config, config_type=config_type) + is_valid, missing = MachineSetting.check_all_settings( + settings_definition=settings, + machine_config=self.machine_config, + config_type=config_type, + ) missing_settings[config_type] = missing - return all(len(missing) == 0 for missing in missing_settings.values()), missing_settings + return all( + len(missing) == 0 for missing in missing_settings.values() + ), missing_settings def set_status(self, status: MachineStatus): """Set the machine status code. There are predefined ones for each MachineType. diff --git a/InvenTree/machine/machine_types/LabelPrintingMachineType.py b/InvenTree/machine/machine_types/LabelPrintingMachineType.py index 79c3108df44..e1160e01c8b 100644 --- a/InvenTree/machine/machine_types/LabelPrintingMachineType.py +++ b/InvenTree/machine/machine_types/LabelPrintingMachineType.py @@ -28,8 +28,9 @@ class LabelPrintingMachineType(BaseMachineType): class LabelPrinterStatus(MachineStatus): CONNECTED = 100, _('Connected'), 'success' - PRINTING = 101, _('Printing'), 'primary' - PAPER_MISSING = 301, _('Paper missing'), 'warning' + STANDBY = 101, _('Standby'), 'success' + PRINTING = 110, _('Printing'), 'primary' + LABEL_SPOOL_EMPTY = 301, _('Label spool empty'), 'warning' DISCONNECTED = 400, _('Disconnected'), 'danger' MACHINE_STATUS = LabelPrinterStatus diff --git a/InvenTree/machine/models.py b/InvenTree/machine/models.py index a0293581411..a33e1c9b35a 100755 --- a/InvenTree/machine/models.py +++ b/InvenTree/machine/models.py @@ -19,37 +19,37 @@ class MachineConfig(models.Model): name = models.CharField( unique=True, max_length=255, - verbose_name=_("Name"), - help_text=_("Name of machine") + verbose_name=_('Name'), + help_text=_('Name of machine'), ) machine_type = models.CharField( - max_length=255, - verbose_name=_("Machine Type"), - help_text=_("Type of machine"), + max_length=255, verbose_name=_('Machine Type'), help_text=_('Type of machine') ) driver = models.CharField( max_length=255, - verbose_name=_("Driver"), - help_text=_("Driver used for the machine") + verbose_name=_('Driver'), + help_text=_('Driver used for the machine'), ) active = models.BooleanField( - default=True, - verbose_name=_("Active"), - help_text=_("Machines can be disabled") + default=True, verbose_name=_('Active'), help_text=_('Machines can be disabled') ) def __str__(self) -> str: """String representation of a machine.""" - return f"{self.name}" + return f'{self.name}' def save(self, *args, **kwargs) -> None: created = self._state.adding old_machine = None - if not created and self.pk and (old_machine := MachineConfig.objects.get(pk=self.pk)): + if ( + not created + and self.pk + and (old_machine := MachineConfig.objects.get(pk=self.pk)) + ): old_machine = old_machine.to_dict() super().save(*args, **kwargs) @@ -72,7 +72,10 @@ def delete(self, *args, **kwargs): def to_dict(self): machine = {f.name: f.value_to_string(self) for f in self._meta.fields} - machine['settings'] = {setting.key: (setting.value, setting.config_type) for setting in MachineSetting.objects.filter(machine_config=self)} + machine['settings'] = { + (setting.config_type, setting.key): setting.value + for setting in MachineSetting.objects.filter(machine_config=self) + } return machine @property @@ -81,28 +84,30 @@ def machine(self): @property def errors(self): - return getattr(self.machine, "errors", []) + return getattr(self.machine, 'errors', []) - @admin.display(boolean=True, description=_("Driver available")) + @admin.display(boolean=True, description=_('Driver available')) def is_driver_available(self) -> bool: """Status if driver for machine is available""" return self.machine is not None and self.machine.driver is not None - @admin.display(boolean=True, description=_("No errors")) + @admin.display(boolean=True, description=_('No errors')) def no_errors(self) -> bool: """Status if machine has errors""" return len(self.errors) == 0 - @admin.display(boolean=True, description=_("Initialized")) + @admin.display(boolean=True, description=_('Initialized')) def initialized(self) -> bool: """Status if machine is initialized""" - return getattr(self.machine, "initialized", False) + return getattr(self.machine, 'initialized', False) - @admin.display(description=_("Errors")) + @admin.display(description=_('Errors')) def get_admin_errors(self): - return format_html_join(mark_safe("
"), "{}", ((str(error),) for error in self.errors)) or mark_safe(f"{_('No errors')}") + return format_html_join( + mark_safe('
'), '{}', ((str(error),) for error in self.errors) + ) or mark_safe(f"{_('No errors')}") - @admin.display(description=_("Machine status")) + @admin.display(description=_('Machine status')) def get_machine_status(self): if self.machine is None: return None @@ -110,7 +115,7 @@ def get_machine_status(self): out = mark_safe(self.machine.status.render(self.machine.status)) if self.machine.status_text: - out += escape(f" ({self.machine.status_text})") + out += escape(f' ({self.machine.status_text})') return out @@ -118,37 +123,41 @@ def get_machine_status(self): class MachineSetting(common.models.BaseInvenTreeSetting): """This models represents settings for individual machines.""" - typ = "machine_config" - extra_unique_fields = ["machine_config", "config_type"] + typ = 'machine_config' + extra_unique_fields = ['machine_config', 'config_type'] class Meta: """Meta for MachineSetting.""" - unique_together = [ - ("machine_config", "config_type", "key") - ] + + unique_together = [('machine_config', 'config_type', 'key')] class ConfigType(models.TextChoices): - MACHINE = "M", _("Machine") - DRIVER = "D", _("Driver") + MACHINE = 'M', _('Machine') + DRIVER = 'D', _('Driver') machine_config = models.ForeignKey( MachineConfig, - related_name="settings", - verbose_name=_("Machine Config"), - on_delete=models.CASCADE + related_name='settings', + verbose_name=_('Machine Config'), + on_delete=models.CASCADE, ) config_type = models.CharField( - verbose_name=_("Config type"), - max_length=1, - choices=ConfigType.choices, + verbose_name=_('Config type'), max_length=1, choices=ConfigType.choices ) + def save(self, *args, **kwargs) -> None: + old_machine = self.machine_config.to_dict() + + super().save(*args, **kwargs) + + registry.update_machine(old_machine, self.machine_config) + @classmethod - def get_config_type(cls, config_type_str: Literal["M", "D"]): - if config_type_str == "M": + def get_config_type(cls, config_type_str: Literal['M', 'D']): + if config_type_str == 'M': return cls.ConfigType.MACHINE - elif config_type_str == "D": + elif config_type_str == 'D': return cls.ConfigType.DRIVER @classmethod @@ -166,7 +175,7 @@ def get_setting_definition(cls, key, **kwargs): if 'settings' not in kwargs: machine_config: MachineConfig = kwargs.pop('machine_config', None) if machine_config and machine_config.machine: - config_type = kwargs.get("config_type", None) + config_type = kwargs.get('config_type', None) if config_type == cls.ConfigType.DRIVER: kwargs['settings'] = machine_config.machine.driver_settings elif config_type == cls.ConfigType.MACHINE: diff --git a/InvenTree/machine/registry.py b/InvenTree/machine/registry.py index fcd3454224b..cd725f16996 100644 --- a/InvenTree/machine/registry.py +++ b/InvenTree/machine/registry.py @@ -7,13 +7,12 @@ logger = logging.getLogger('inventree') -class MachinesRegistry: +class MachineRegistry: def __init__(self) -> None: """Initialize machine registry Set up all needed references for internal and external states. """ - self.machine_types: Dict[str, Type[BaseMachineType]] = {} self.drivers: Dict[str, Type[BaseDriver]] = {} self.driver_instances: Dict[str, BaseDriver] = {} @@ -23,7 +22,7 @@ def __init__(self) -> None: self.errors = [] def initialize(self): - print("INITIALIZE") # TODO: remove debug statement + print('INITIALIZE') # TODO: remove debug statement self.discover_machine_types() self.discover_drivers() self.load_machines() @@ -31,12 +30,14 @@ def initialize(self): def discover_machine_types(self): import InvenTree.helpers - logger.debug("Collecting machine types") + 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) + discovered_machine_types: Set[Type[BaseMachineType]] = ( + InvenTree.helpers.inheritors(BaseMachineType) + ) for machine_type in discovered_machine_types: try: machine_type.validate() @@ -45,7 +46,9 @@ def discover_machine_types(self): continue if machine_type.SLUG in machine_types: - self.errors.append(ValueError(f"Cannot re-register machine type '{machine_type.SLUG}'")) + self.errors.append( + ValueError(f"Cannot re-register machine type '{machine_type.SLUG}'") + ) continue machine_types[machine_type.SLUG] = machine_type @@ -54,16 +57,18 @@ def discover_machine_types(self): self.machine_types = machine_types self.base_drivers = base_drivers - logger.debug(f"Found {len(self.machine_types.keys())} machine types") + logger.debug(f'Found {len(self.machine_types.keys())} machine types') def discover_drivers(self): import InvenTree.helpers - logger.debug("Collecting machine drivers") + logger.debug('Collecting machine drivers') drivers: Dict[str, Type[BaseDriver]] = {} - discovered_drivers: Set[Type[BaseDriver]] = InvenTree.helpers.inheritors(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: @@ -76,14 +81,16 @@ def discover_drivers(self): continue if driver.SLUG in drivers: - self.errors.append(ValueError(f"Cannot re-register driver '{driver.SLUG}'")) + self.errors.append( + ValueError(f"Cannot re-register driver '{driver.SLUG}'") + ) continue drivers[driver.SLUG] = driver self.drivers = drivers - logger.debug(f"Found {len(self.drivers.keys())} machine drivers") + logger.debug(f'Found {len(self.drivers.keys())} machine drivers') def get_driver_instance(self, slug: str): if slug not in self.driver_instances: @@ -110,7 +117,9 @@ def load_machines(self): def add_machine(self, machine_config, initialize=True): machine_type = self.machine_types.get(machine_config.machine_type, None) if machine_type is None: - self.errors.append(f"Machine type '{machine_config.machine_type}' not found") + self.errors.append( + f"Machine type '{machine_config.machine_type}' not found" + ) return machine: BaseMachineType = machine_type(machine_config) @@ -120,8 +129,11 @@ def add_machine(self, machine_config, initialize=True): machine.initialize() def update_machine(self, old_machine_state, machine_config): - if (machine := machine_config.machine) and machine.driver: - machine.driver.update_machine(old_machine_state, machine) + if machine := machine_config.machine: + machine.update(old_machine_state) + + def restart_machine(self, machine): + machine.restart() def remove_machine(self, machine: BaseMachineType): self.machines.pop(str(machine.pk), None) @@ -137,7 +149,14 @@ def get_machines(self, **kwargs): active: (bool) base_driver: base driver (class) """ - allowed_fields = ["name", "machine_type", "driver", "initialized", "active", "base_driver"] + allowed_fields = [ + 'name', + 'machine_type', + 'driver', + 'initialized', + 'active', + 'base_driver', + ] kwargs = {'initialized': True, **kwargs} @@ -147,12 +166,14 @@ def filter_machine(machine: BaseMachineType): continue # check if current driver is subclass from base_driver - if key == "base_driver": - if machine.driver and not issubclass(machine.driver.__class__, value): + if key == 'base_driver': + if machine.driver and not issubclass( + machine.driver.__class__, value + ): return False # check if current machine is subclass from machine_type - elif key == "machine_type": + elif key == 'machine_type': if issubclass(machine.__class__, value): return False @@ -169,4 +190,4 @@ def get_machine(self, pk: Union[str, UUID]): return self.machines.get(str(pk), None) -registry: MachinesRegistry = MachinesRegistry() +registry: MachineRegistry = MachineRegistry() diff --git a/InvenTree/machine/serializers.py b/InvenTree/machine/serializers.py index 52f3293504f..5a763439c66 100644 --- a/InvenTree/machine/serializers.py +++ b/InvenTree/machine/serializers.py @@ -26,6 +26,7 @@ class Meta: 'status_text', 'machine_errors', 'is_driver_available', + 'restart_required', ] read_only_fields = ['machine_type', 'driver'] @@ -36,6 +37,7 @@ class Meta: status_text = serializers.SerializerMethodField('get_status_text') machine_errors = serializers.SerializerMethodField('get_errors') is_driver_available = serializers.SerializerMethodField('get_is_driver_available') + restart_required = serializers.SerializerMethodField('get_restart_required') def get_initialized(self, obj: MachineConfig) -> bool: return getattr(obj.machine, 'initialized', False) @@ -60,6 +62,9 @@ def get_errors(self, obj: MachineConfig) -> List[str]: def get_is_driver_available(self, obj: MachineConfig) -> bool: return obj.is_driver_available() + def get_restart_required(self, obj: MachineConfig) -> bool: + return getattr(obj.machine, 'restart_required', False) + class MachineConfigCreateSerializer(MachineConfigSerializer): """Serializer for creating a MachineConfig.""" @@ -168,3 +173,12 @@ class Meta: fields = ['registry_errors'] registry_errors = serializers.ListField(child=MachineRegistryErrorSerializer()) + + +class MachineRestartSerializer(serializers.Serializer): + """Serializer for the machine restart response.""" + + class Meta: + fields = ['ok'] + + ok = serializers.BooleanField() diff --git a/src/frontend/src/components/items/ActionDropdown.tsx b/src/frontend/src/components/items/ActionDropdown.tsx index 32854f5e537..f26408c6e1e 100644 --- a/src/frontend/src/components/items/ActionDropdown.tsx +++ b/src/frontend/src/components/items/ActionDropdown.tsx @@ -1,5 +1,11 @@ import { t } from '@lingui/macro'; -import { ActionIcon, Menu, Tooltip } from '@mantine/core'; +import { + ActionIcon, + Indicator, + IndicatorProps, + Menu, + Tooltip +} from '@mantine/core'; import { IconCopy, IconEdit, @@ -18,6 +24,7 @@ export type ActionDropdownItem = { tooltip?: string; disabled?: boolean; onClick?: () => void; + indicator?: Omit; }; /** @@ -37,34 +44,41 @@ export function ActionDropdown({ const hasActions = useMemo(() => { return actions.some((action) => !action.disabled); }, [actions]); + const indicatorProps = useMemo(() => { + return actions.find((action) => action.indicator); + }, [actions]); return hasActions ? ( - - - + + + + + {actions.map((action) => action.disabled ? null : ( - - { - if (action.onClick != undefined) { - action.onClick(); - } else { - notYetImplemented(); - } - }} - disabled={action.disabled} - > - {action.name} - - + + + { + if (action.onClick != undefined) { + action.onClick(); + } else { + notYetImplemented(); + } + }} + disabled={action.disabled} + > + {action.name} + + + ) )} diff --git a/src/frontend/src/components/settings/SettingItem.tsx b/src/frontend/src/components/settings/SettingItem.tsx index 28deffc3a10..e3089fe424d 100644 --- a/src/frontend/src/components/settings/SettingItem.tsx +++ b/src/frontend/src/components/settings/SettingItem.tsx @@ -26,10 +26,12 @@ import { ApiFormFieldType } from '../forms/fields/ApiFormField'; */ function SettingValue({ settingsState, - setting + setting, + onChange }: { settingsState: SettingsStateProps; setting: Setting; + onChange?: () => void; }) { // Callback function when a boolean value is changed function onToggle(value: boolean) { @@ -45,6 +47,7 @@ function SettingValue({ color: 'green' }); settingsState.fetchSettings(); + onChange?.(); }) .catch((error) => { console.log('Error editing setting', error); @@ -98,6 +101,7 @@ function SettingValue({ color: 'green' }); settingsState.fetchSettings(); + onChange?.(); } }); } @@ -154,11 +158,13 @@ function SettingValue({ export function SettingItem({ settingsState, setting, - shaded + shaded, + onChange }: { settingsState: SettingsStateProps; setting: Setting; shaded: boolean; + onChange?: () => void; }) { const theme = useMantineTheme(); @@ -180,7 +186,11 @@ export function SettingItem({ {setting.description} - + ); diff --git a/src/frontend/src/components/settings/SettingList.tsx b/src/frontend/src/components/settings/SettingList.tsx index 8e0f1c2dc98..b2691a519bf 100644 --- a/src/frontend/src/components/settings/SettingList.tsx +++ b/src/frontend/src/components/settings/SettingList.tsx @@ -17,10 +17,12 @@ import { SettingItem } from './SettingItem'; */ export function SettingList({ settingsState, - keys + keys, + onChange }: { settingsState: SettingsStateProps; keys?: string[]; + onChange?: () => void; }) { useEffect(() => { settingsState.fetchSettings(); @@ -46,6 +48,7 @@ export function SettingList({ settingsState={settingsState} setting={setting} shaded={i % 2 === 0} + onChange={onChange} /> ) : ( @@ -88,10 +91,12 @@ export function PluginSettingList({ pluginPk }: { pluginPk: string }) { export function MachineSettingList({ machinePk, - configType + configType, + onChange }: { machinePk: string; configType: 'M' | 'D'; + onChange?: () => void; }) { const machineSettingsStore = useRef( createMachineSettingsState({ @@ -101,5 +106,5 @@ export function MachineSettingList({ ).current; const machineSettings = useStore(machineSettingsStore); - return ; + return ; } diff --git a/src/frontend/src/components/tables/machine/MachineListTable.tsx b/src/frontend/src/components/tables/machine/MachineListTable.tsx index dfb9586986f..e81752a58ae 100644 --- a/src/frontend/src/components/tables/machine/MachineListTable.tsx +++ b/src/frontend/src/components/tables/machine/MachineListTable.tsx @@ -15,7 +15,8 @@ import { Text, Title } from '@mantine/core'; -import { IconDots, IconRefresh } from '@tabler/icons-react'; +import { notifications } from '@mantine/notifications'; +import { IconCheck, IconDots, IconRefresh } from '@tabler/icons-react'; import { useQuery } from '@tanstack/react-query'; import { useCallback, useMemo, useState } from 'react'; import { useNavigate } from 'react-router-dom'; @@ -59,6 +60,7 @@ interface MachineI { status_text: string; machine_errors: string[]; is_driver_available: boolean; + restart_required: boolean; } function MachineStatusIndicator({ machine }: { machine: MachineI }) { @@ -174,6 +176,24 @@ function MachineDrawer({ [machine?.driver, machineDrivers] ); + const restartMachine = useCallback( + (machinePk: string) => { + api + .post( + apiUrl(ApiPaths.machine_restart, undefined, { machine: machinePk }) + ) + .then(() => { + refetch(); + notifications.show({ + message: t`Machine restarted`, + color: 'green', + icon: + }); + }); + }, + [refetch] + ); + return ( @@ -217,7 +237,20 @@ function MachineDrawer({ onFormSuccess: () => navigate(-1) }); } - }) + }), + { + icon: , + name: t`Restart`, + tooltip: + t`Restart machine` + + (machine?.restart_required + ? ' (' + t`manual restart required` + ')' + : ''), + indicator: machine?.restart_required + ? { color: 'red' } + : undefined, + onClick: () => machine && restartMachine(machine?.pk) + } ]} /> @@ -309,14 +342,22 @@ function MachineDrawer({ <Trans>Machine Settings</Trans> - + <Trans>Driver Settings</Trans> - + )} diff --git a/src/frontend/src/enums/ApiEndpoints.tsx b/src/frontend/src/enums/ApiEndpoints.tsx index 03177aaff32..b2a322fecff 100644 --- a/src/frontend/src/enums/ApiEndpoints.tsx +++ b/src/frontend/src/enums/ApiEndpoints.tsx @@ -107,6 +107,7 @@ export enum ApiPaths { machine_driver_list = 'api-machine-drivers', machine_registry_status = 'api-machine-registry-status', machine_list = 'api-machine-list', + machine_restart = 'api-machine-restart', machine_setting_list = 'api-machine-settings', machine_setting_detail = 'api-machine-settings-detail' } diff --git a/src/frontend/src/states/ApiState.tsx b/src/frontend/src/states/ApiState.tsx index 4fc2b3e8827..52da8fb79ae 100644 --- a/src/frontend/src/states/ApiState.tsx +++ b/src/frontend/src/states/ApiState.tsx @@ -229,6 +229,8 @@ export function apiEndpoint(path: ApiPaths): string { return 'machine/status/'; case ApiPaths.machine_list: return 'machine/'; + case ApiPaths.machine_restart: + return 'machine/:machine/restart/'; case ApiPaths.machine_setting_list: return 'machine/:machine/settings/'; case ApiPaths.machine_setting_detail: From b454a489cff53c9476d9b3fa1f79cb7345e5ad8b Mon Sep 17 00:00:00 2001 From: wolflu05 <76838159+wolflu05@users.noreply.github.com> Date: Sat, 27 Jan 2024 15:55:06 +0000 Subject: [PATCH 45/86] Added restart requird badge to machine table/drawer --- .../tables/machine/MachineListTable.tsx | 121 ++++++++++-------- 1 file changed, 69 insertions(+), 52 deletions(-) diff --git a/src/frontend/src/components/tables/machine/MachineListTable.tsx b/src/frontend/src/components/tables/machine/MachineListTable.tsx index e81752a58ae..3ccd93ddb1b 100644 --- a/src/frontend/src/components/tables/machine/MachineListTable.tsx +++ b/src/frontend/src/components/tables/machine/MachineListTable.tsx @@ -176,6 +176,11 @@ function MachineDrawer({ [machine?.driver, machineDrivers] ); + const refreshAll = useCallback(() => { + refetch(); + refreshTable(); + }, [refetch, refreshTable]); + const restartMachine = useCallback( (machinePk: string) => { api @@ -183,7 +188,7 @@ function MachineDrawer({ apiUrl(ApiPaths.machine_restart, undefined, { machine: machinePk }) ) .then(() => { - refetch(); + refreshAll(); notifications.show({ message: t`Machine restarted`, color: 'green', @@ -191,7 +196,7 @@ function MachineDrawer({ }); }); }, - [refetch] + [refreshAll] ); return ( @@ -204,55 +209,62 @@ function MachineDrawer({ {machine?.name} - } - actions={[ - EditItemAction({ - tooltip: t`Edit machine`, - onClick: () => { - openEditApiForm({ - title: t`Edit machine`, - url: ApiPaths.machine_list, - pk: machinePk, - fields: { - name: {}, - active: {} - }, - onClose: () => refetch() - }); - } - }), - DeleteItemAction({ - tooltip: t`Delete machine`, - onClick: () => { - openDeleteApiForm({ - title: t`Delete machine`, - successMessage: t`Machine successfully deleted.`, - url: ApiPaths.machine_list, - pk: machinePk, - preFormContent: ( - {t`Are you sure you want to remove the machine "${machine?.name}"?`} - ), - onFormSuccess: () => navigate(-1) - }); + + {machine?.restart_required && ( + + Restart required + + )} + } + actions={[ + EditItemAction({ + tooltip: t`Edit machine`, + onClick: () => { + openEditApiForm({ + title: t`Edit machine`, + url: ApiPaths.machine_list, + pk: machinePk, + fields: { + name: {}, + active: {} + }, + onClose: () => refreshAll() + }); + } + }), + DeleteItemAction({ + tooltip: t`Delete machine`, + onClick: () => { + openDeleteApiForm({ + title: t`Delete machine`, + successMessage: t`Machine successfully deleted.`, + url: ApiPaths.machine_list, + pk: machinePk, + preFormContent: ( + {t`Are you sure you want to remove the machine "${machine?.name}"?`} + ), + onFormSuccess: () => navigate(-1) + }); + } + }), + { + icon: , + name: t`Restart`, + tooltip: + t`Restart machine` + + (machine?.restart_required + ? ' (' + t`manual restart required` + ')' + : ''), + indicator: machine?.restart_required + ? { color: 'red' } + : undefined, + onClick: () => machine && restartMachine(machine?.pk) } - }), - { - icon: , - name: t`Restart`, - tooltip: - t`Restart machine` + - (machine?.restart_required - ? ' (' + t`manual restart required` + ')' - : ''), - indicator: machine?.restart_required - ? { color: 'red' } - : undefined, - onClick: () => machine && restartMachine(machine?.pk) - } - ]} - /> + ]} + /> + @@ -345,7 +357,7 @@ function MachineDrawer({ @@ -356,7 +368,7 @@ function MachineDrawer({ @@ -385,6 +397,11 @@ export function MachineListTable({ props }: { props: InvenTreeTableProps }) { {record.name} + {record.restart_required && ( + + Restart required + + )} ); } From d321d5ef1dc82a152b04fbde17e0509f4c8462f6 Mon Sep 17 00:00:00 2001 From: wolflu05 <76838159+wolflu05@users.noreply.github.com> Date: Sat, 27 Jan 2024 17:23:04 +0000 Subject: [PATCH 46/86] Added driver init function --- InvenTree/machine/machine_type.py | 8 ++++++++ InvenTree/machine/registry.py | 4 ++++ 2 files changed, 12 insertions(+) diff --git a/InvenTree/machine/machine_type.py b/InvenTree/machine/machine_type.py index 332b0141a98..b65418235e7 100644 --- a/InvenTree/machine/machine_type.py +++ b/InvenTree/machine/machine_type.py @@ -60,6 +60,14 @@ class BaseDriver(ClassValidationMixin, ClassProviderMixin): required_attributes = ['SLUG', 'NAME', 'DESCRIPTION', 'machine_type'] + def init_driver(self): + """This method gets called after all machines are created and can be used to initialize the driver. + + After the driver is initialized, the self.init_machine function is + called for each machine associated with that driver. + """ + pass + def init_machine(self, machine: 'BaseMachineType'): """This method gets called for each active machine using that driver while initialization. diff --git a/InvenTree/machine/registry.py b/InvenTree/machine/registry.py index cd725f16996..c5a8d38d0b6 100644 --- a/InvenTree/machine/registry.py +++ b/InvenTree/machine/registry.py @@ -109,6 +109,10 @@ def load_machines(self): for machine_config in MachineConfig.objects.all(): self.add_machine(machine_config, initialize=False) + # 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: From fe2ba8e1435a6a15bd9f9f833389885f49bc85e7 Mon Sep 17 00:00:00 2001 From: wolflu05 <76838159+wolflu05@users.noreply.github.com> Date: Sun, 28 Jan 2024 10:23:17 +0000 Subject: [PATCH 47/86] handle error functions for machines and registry --- InvenTree/machine/machine_type.py | 27 +++++++++++++-------------- InvenTree/machine/registry.py | 17 +++++++++-------- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/InvenTree/machine/machine_type.py b/InvenTree/machine/machine_type.py index b65418235e7..e1b953c8615 100644 --- a/InvenTree/machine/machine_type.py +++ b/InvenTree/machine/machine_type.py @@ -1,4 +1,4 @@ -from typing import TYPE_CHECKING, Any, Dict, List, Literal, Tuple, Type +from typing import TYPE_CHECKING, Any, Dict, List, Literal, Tuple, Type, Union from generic.states import StatusCode from InvenTree.helpers_mixin import ClassProviderMixin, ClassValidationMixin @@ -162,9 +162,9 @@ def __init__(self, machine_config: MachineConfig) -> None: self.driver = registry.get_driver_instance(machine_config.driver) if not self.driver: - self.errors.append(f"Driver '{machine_config.driver}' not found") + self.handle_error(f"Driver '{machine_config.driver}' not found") if self.driver and not isinstance(self.driver, self.base_driver): - self.errors.append( + self.handle_error( f"'{self.driver.NAME}' is incompatible with machine type '{self.NAME}'" ) @@ -184,11 +184,6 @@ def __init__(self, machine_config: MachineConfig) -> None: self.restart_required = False - if len(self.errors) > 0: - return - - # TODO: add further init stuff here - def __str__(self): return f'{self.name}' @@ -223,14 +218,14 @@ def initialize(self): error_parts.append( f'{config_type.name} settings: ' + ', '.join(missing) ) - self.errors.append(f"Missing {' and '.join(error_parts)}") + self.handle_error(f"Missing {' and '.join(error_parts)}") return try: self.driver.init_machine(self) self.initialized = True except Exception as e: - self.errors.append(e) + self.handle_error(e) def update(self, old_state: dict[str, Any]): """Machine update function, gets called if the machine itself changes or their settings.""" @@ -240,7 +235,7 @@ def update(self, old_state: dict[str, Any]): try: self.driver.update_machine(old_state, self) except Exception as e: - self.errors.append(e) + self.handle_error(e) def restart(self): """Machine restart function, can be used to manually restart the machine from the admin ui.""" @@ -251,10 +246,14 @@ def restart(self): self.restart_required = False self.driver.restart_machine(self) except Exception as e: - self.errors.append(e) + self.handle_error(e) # --- helper functions - def get_setting(self, key, config_type_str: Literal['M', 'D'], cache=False): + def handle_error(self, error: Union[Exception, str]): + """Helper function for capturing errors with the machine.""" + self.errors.append(error) + + def get_setting(self, key: str, config_type_str: Literal['M', 'D'], cache=False): """Return the 'value' of the setting associated with this machine. Arguments: @@ -272,7 +271,7 @@ def get_setting(self, key, config_type_str: Literal['M', 'D'], cache=False): cache=cache, ) - def set_setting(self, key, config_type_str: Literal['M', 'D'], value): + def set_setting(self, key: str, config_type_str: Literal['M', 'D'], value): """Set plugin setting value by key. Arguments: diff --git a/InvenTree/machine/registry.py b/InvenTree/machine/registry.py index c5a8d38d0b6..cad44fe4c20 100644 --- a/InvenTree/machine/registry.py +++ b/InvenTree/machine/registry.py @@ -21,8 +21,11 @@ def __init__(self) -> None: self.base_drivers: List[Type[BaseDriver]] = [] self.errors = [] + def handle_error(self, error: Union[Exception, str]): + """Helper function for capturing errors with the machine registry.""" + self.errors.append(error) + def initialize(self): - print('INITIALIZE') # TODO: remove debug statement self.discover_machine_types() self.discover_drivers() self.load_machines() @@ -42,11 +45,11 @@ def discover_machine_types(self): try: machine_type.validate() except NotImplementedError as error: - self.errors.append(error) + self.handle_error(error) continue if machine_type.SLUG in machine_types: - self.errors.append( + self.handle_error( ValueError(f"Cannot re-register machine type '{machine_type.SLUG}'") ) continue @@ -77,11 +80,11 @@ def discover_drivers(self): try: driver.validate() except NotImplementedError as error: - self.errors.append(error) + self.handle_error(error) continue if driver.SLUG in drivers: - self.errors.append( + self.handle_error( ValueError(f"Cannot re-register driver '{driver.SLUG}'") ) continue @@ -121,9 +124,7 @@ def load_machines(self): def add_machine(self, machine_config, initialize=True): machine_type = self.machine_types.get(machine_config.machine_type, None) if machine_type is None: - self.errors.append( - f"Machine type '{machine_config.machine_type}' not found" - ) + self.handle_error(f"Machine type '{machine_config.machine_type}' not found") return machine: BaseMachineType = machine_type(machine_config) From be0abc0a975440951d3899492b70ecebe83f3999 Mon Sep 17 00:00:00 2001 From: wolflu05 <76838159+wolflu05@users.noreply.github.com> Date: Sun, 28 Jan 2024 10:45:41 +0000 Subject: [PATCH 48/86] Added driver errors --- InvenTree/machine/machine_type.py | 12 +++++++-- InvenTree/machine/registry.py | 2 +- InvenTree/machine/serializers.py | 11 +++++++- .../tables/machine/MachineTypeTable.tsx | 25 +++++++++++++++++++ 4 files changed, 46 insertions(+), 4 deletions(-) diff --git a/InvenTree/machine/machine_type.py b/InvenTree/machine/machine_type.py index e1b953c8615..b9a90b08f1a 100644 --- a/InvenTree/machine/machine_type.py +++ b/InvenTree/machine/machine_type.py @@ -60,6 +60,11 @@ class BaseDriver(ClassValidationMixin, ClassProviderMixin): required_attributes = ['SLUG', 'NAME', 'DESCRIPTION', 'machine_type'] + def __init__(self) -> None: + 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. @@ -110,6 +115,9 @@ def get_machines(self, **kwargs): return registry.get_machines(driver=self, **kwargs) + def handle_error(self, error: Union[Exception, str]): + self.errors.append(error) + class BaseMachineType(ClassValidationMixin, ClassProviderMixin): """Base class for machine types @@ -152,11 +160,11 @@ def __init__(self, machine_config: MachineConfig) -> None: from machine import registry from machine.models import MachineSetting - self.errors = [] + self.errors: list[Union[str, Exception]] = [] self.initialized = False self.status = self.default_machine_status - self.status_text = '' + self.status_text: str = '' self.pk = machine_config.pk self.driver = registry.get_driver_instance(machine_config.driver) diff --git a/InvenTree/machine/registry.py b/InvenTree/machine/registry.py index cad44fe4c20..4b308e77d33 100644 --- a/InvenTree/machine/registry.py +++ b/InvenTree/machine/registry.py @@ -19,7 +19,7 @@ def __init__(self) -> None: self.machines: Dict[str, BaseMachineType] = {} self.base_drivers: List[Type[BaseDriver]] = [] - self.errors = [] + self.errors: list[Union[str, Exception]] = [] def handle_error(self, error: Union[Exception, str]): """Helper function for capturing errors with the machine registry.""" diff --git a/InvenTree/machine/serializers.py b/InvenTree/machine/serializers.py index 5a763439c66..ba5983ee1c9 100644 --- a/InvenTree/machine/serializers.py +++ b/InvenTree/machine/serializers.py @@ -4,6 +4,7 @@ from common.serializers import GenericReferencedSettingSerializer from InvenTree.helpers_mixin import ClassProviderMixin +from machine import registry from machine.models import MachineConfig, MachineSetting @@ -152,10 +153,18 @@ class MachineDriverSerializer(BaseMachineClassSerializer): class Meta(BaseMachineClassSerializer.Meta): """Meta for a serializer.""" - fields = [*BaseMachineClassSerializer.Meta.fields, 'machine_type'] + fields = [*BaseMachineClassSerializer.Meta.fields, 'machine_type', 'errors'] machine_type = serializers.SlugField(read_only=True) + driver_errors = serializers.SerializerMethodField('get_errors') + + def get_errors(self, obj) -> List[str]: + driver_instance = registry.driver_instances.get(obj.SLUG, None) + if driver_instance is None: + return [] + return [str(err) for err in driver_instance.errors] + class MachineRegistryErrorSerializer(serializers.Serializer): """Serializer for a machine registry error.""" diff --git a/src/frontend/src/components/tables/machine/MachineTypeTable.tsx b/src/frontend/src/components/tables/machine/MachineTypeTable.tsx index dcb8aca6dc0..9449c0c1289 100644 --- a/src/frontend/src/components/tables/machine/MachineTypeTable.tsx +++ b/src/frontend/src/components/tables/machine/MachineTypeTable.tsx @@ -1,8 +1,11 @@ import { Trans, t } from '@lingui/macro'; import { ActionIcon, + Badge, Card, + Code, Group, + List, LoadingOverlay, Stack, Text, @@ -39,6 +42,7 @@ export interface MachineDriverI { provider_plugin: { slug: string; name: string; pk: number | null } | null; is_builtin: boolean; machine_type: string; + driver_errors: string[]; } function MachineTypeDrawer({ machineTypeSlug }: { machineTypeSlug: string }) { @@ -238,6 +242,27 @@ function MachineDriverDrawer({ value={machineDriver?.is_builtin} type="boolean" /> + + + Errors: + + {machineDriver && machineDriver?.driver_errors.length > 0 ? ( + + {machineDriver.driver_errors.length} + + ) : ( + + No errors reported + + )} + + {machineDriver?.driver_errors.map((error, i) => ( + + {error} + + ))} + + From 0424cff3bc3fa06c568db1ac154a6506015fa1d5 Mon Sep 17 00:00:00 2001 From: wolflu05 <76838159+wolflu05@users.noreply.github.com> Date: Sun, 28 Jan 2024 11:11:22 +0000 Subject: [PATCH 49/86] Added machine table to driver drawer --- .../tables/machine/MachineListTable.tsx | 55 +++++++++++++------ .../tables/machine/MachineTypeTable.tsx | 19 ++++++- 2 files changed, 57 insertions(+), 17 deletions(-) diff --git a/src/frontend/src/components/tables/machine/MachineListTable.tsx b/src/frontend/src/components/tables/machine/MachineListTable.tsx index 3ccd93ddb1b..aef75cfd6ff 100644 --- a/src/frontend/src/components/tables/machine/MachineListTable.tsx +++ b/src/frontend/src/components/tables/machine/MachineListTable.tsx @@ -380,7 +380,15 @@ function MachineDrawer({ /** * Table displaying list of available plugins */ -export function MachineListTable({ props }: { props: InvenTreeTableProps }) { +export function MachineListTable({ + props, + renderMachineDrawer = true, + createProps +}: { + props: InvenTreeTableProps; + renderMachineDrawer?: boolean; + createProps?: { machine_type?: string; driver?: string }; +}) { const { machineTypes, machineDrivers } = useMachineTypeDriver(); const table = useTable('machine'); @@ -483,6 +491,10 @@ export function MachineListTable({ props }: { props: InvenTreeTableProps }) { fields: { name: {}, machine_type: { + hidden: !!createProps?.machine_type, + ...(createProps?.machine_type + ? { value: createProps.machine_type } + : {}), field_type: 'choice', choices: machineTypes ? machineTypes.map((t) => ({ @@ -493,6 +505,8 @@ export function MachineListTable({ props }: { props: InvenTreeTableProps }) { onValueChange: (value) => setCreateFormMachineType(value) }, driver: { + hidden: !!createProps?.driver, + ...(createProps?.driver ? { value: createProps.driver } : {}), field_type: 'choice', disabled: !createFormMachineType, choices: createFormDriverOptions @@ -501,7 +515,9 @@ export function MachineListTable({ props }: { props: InvenTreeTableProps }) { }, onFormSuccess: (data) => { table.refreshTable(); - navigate(`machine-${data.pk}/`); + navigate( + renderMachineDrawer ? `machine-${data.pk}/` : `../machine-${data.pk}/` + ); }, onClose: () => { setCreateFormMachineType(null); @@ -523,19 +539,21 @@ export function MachineListTable({ props }: { props: InvenTreeTableProps }) { return ( <> {createMachineForm.modal} - { - if (!id || !id.startsWith('machine-')) return false; - return ( - - ); - }} - /> + {renderMachineDrawer && ( + { + if (!id || !id.startsWith('machine-')) return false; + return ( + + ); + }} + /> + )} navigate(`machine-${machine.pk}/`), + onRowClick: (machine) => + navigate( + renderMachineDrawer + ? `machine-${machine.pk}/` + : `../machine-${machine.pk}/` + ), tableActions, params: { ...props.params diff --git a/src/frontend/src/components/tables/machine/MachineTypeTable.tsx b/src/frontend/src/components/tables/machine/MachineTypeTable.tsx index 9449c0c1289..b4684c93a27 100644 --- a/src/frontend/src/components/tables/machine/MachineTypeTable.tsx +++ b/src/frontend/src/components/tables/machine/MachineTypeTable.tsx @@ -23,7 +23,7 @@ import { DetailDrawer } from '../../nav/DetailDrawer'; import { TableColumn } from '../Column'; import { BooleanColumn } from '../ColumnRenderers'; import { InvenTreeTable, InvenTreeTableProps } from '../InvenTreeTable'; -import { useMachineTypeDriver } from './MachineListTable'; +import { MachineListTable, useMachineTypeDriver } from './MachineListTable'; export interface MachineTypeI { slug: string; @@ -266,6 +266,23 @@ function MachineDriverDrawer({ + + + + + <Trans>Machines</Trans> + + + + + ); } From badbb1debac69f1c662e476a46602bdd7b72df25 Mon Sep 17 00:00:00 2001 From: wolflu05 <76838159+wolflu05@users.noreply.github.com> Date: Sun, 28 Jan 2024 11:22:30 +0000 Subject: [PATCH 50/86] Added back button to detail drawer component --- .../src/components/nav/DetailDrawer.tsx | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/src/frontend/src/components/nav/DetailDrawer.tsx b/src/frontend/src/components/nav/DetailDrawer.tsx index 20ef06f21ca..8c341ef1ef5 100644 --- a/src/frontend/src/components/nav/DetailDrawer.tsx +++ b/src/frontend/src/components/nav/DetailDrawer.tsx @@ -1,4 +1,13 @@ -import { Divider, Drawer, MantineNumberSize, Stack, Text } from '@mantine/core'; +import { + ActionIcon, + Divider, + Drawer, + Group, + MantineNumberSize, + Stack, + Text +} from '@mantine/core'; +import { IconChevronLeft } from '@tabler/icons-react'; import { useMemo } from 'react'; import { Route, Routes, useNavigate, useParams } from 'react-router-dom'; @@ -31,13 +40,18 @@ function DetailDrawerComponent({ return ( navigate(-1)} + onClose={() => navigate('../')} position={position} size={size} title={ - - {title} - + + navigate(-1)}> + + + + {title} + + } overlayProps={{ opacity: 0.5, blur: 4 }} > From 07c89b711390803397be35308d413419acc7bf07 Mon Sep 17 00:00:00 2001 From: wolflu05 <76838159+wolflu05@users.noreply.github.com> Date: Sun, 28 Jan 2024 11:41:55 +0000 Subject: [PATCH 51/86] Fix auto formatable pre-commit --- InvenTree/InvenTree/helpers_mixin.py | 16 +++++++++++----- InvenTree/machine/__init__.py | 8 +------- InvenTree/machine/apps.py | 13 ++++++++----- InvenTree/machine/machine_types/__init__.py | 9 +++++---- InvenTree/plugin/machine/__init__.py | 13 ++++++------- 5 files changed, 31 insertions(+), 28 deletions(-) diff --git a/InvenTree/InvenTree/helpers_mixin.py b/InvenTree/InvenTree/helpers_mixin.py index 7b13f10707e..12e0911c67c 100644 --- a/InvenTree/InvenTree/helpers_mixin.py +++ b/InvenTree/InvenTree/helpers_mixin.py @@ -22,10 +22,12 @@ class ClassValidationMixin: @classmethod def validate(cls): def attribute_missing(key): - return not hasattr(cls, key) or getattr(cls, key) == "" + return not hasattr(cls, key) or getattr(cls, key) == '' def override_missing(base_implementation): - return base_implementation == getattr(cls, base_implementation.__name__, None) + return base_implementation == getattr( + cls, base_implementation.__name__, None + ) missing_attributes = list(filter(attribute_missing, cls.required_attributes)) missing_overrides = list(filter(override_missing, cls.required_overrides)) @@ -33,12 +35,16 @@ def override_missing(base_implementation): errors = [] if len(missing_attributes) > 0: - errors.append(f"did not provide the following attributes: {', '.join(missing_attributes)}") + errors.append( + f"did not provide the following attributes: {', '.join(missing_attributes)}" + ) if len(missing_overrides) > 0: - errors.append(f"did not override the required attributes: {', '.join(map(lambda attr: attr.__name__, missing_overrides))}") + errors.append( + f"did not override the required attributes: {', '.join(map(lambda attr: attr.__name__, missing_overrides))}" + ) if len(errors) > 0: - raise NotImplementedError(f"'{cls}' " + " and ".join(errors)) + raise NotImplementedError(f"'{cls}' " + ' and '.join(errors)) class ClassProviderMixin: diff --git a/InvenTree/machine/__init__.py b/InvenTree/machine/__init__.py index 2487c28c2df..719efa14e9f 100755 --- a/InvenTree/machine/__init__.py +++ b/InvenTree/machine/__init__.py @@ -1,10 +1,4 @@ from machine.machine_type import BaseDriver, BaseMachineType, MachineStatus from machine.registry import registry -__all__ = [ - "registry", - - "BaseMachineType", - "BaseDriver", - "MachineStatus" -] +__all__ = ['registry', 'BaseMachineType', 'BaseDriver', 'MachineStatus'] diff --git a/InvenTree/machine/apps.py b/InvenTree/machine/apps.py index 72c8338773d..052df1b15a5 100755 --- a/InvenTree/machine/apps.py +++ b/InvenTree/machine/apps.py @@ -2,8 +2,7 @@ from django.apps import AppConfig -from InvenTree.ready import (canAppAccessDatabase, isInMainThread, - isPluginRegistryLoaded) +from InvenTree.ready import canAppAccessDatabase, isInMainThread, isPluginRegistryLoaded logger = logging.getLogger('inventree') @@ -13,11 +12,15 @@ class MachineConfig(AppConfig): def ready(self) -> None: """Initialization method for the Machine app.""" - if not canAppAccessDatabase(allow_test=True) or not isPluginRegistryLoaded() or not isInMainThread(): - logger.debug("Machine app: Skipping machine loading sequence") + if ( + not canAppAccessDatabase(allow_test=True) + or not isPluginRegistryLoaded() + or not isInMainThread() + ): + logger.debug('Machine app: Skipping machine loading sequence') return from machine import registry - logger.info("Loading InvenTree machines") + logger.info('Loading InvenTree machines') registry.initialize() diff --git a/InvenTree/machine/machine_types/__init__.py b/InvenTree/machine/machine_types/__init__.py index 263e2060ef9..f59f4adb4b8 100644 --- a/InvenTree/machine/machine_types/__init__.py +++ b/InvenTree/machine/machine_types/__init__.py @@ -1,10 +1,11 @@ from machine.machine_types.LabelPrintingMachineType import ( - BaseLabelPrintingDriver, LabelPrintingMachineType) + BaseLabelPrintingDriver, + LabelPrintingMachineType, +) __all__ = [ # machine types - "LabelPrintingMachineType", - + 'LabelPrintingMachineType', # base drivers - "BaseLabelPrintingDriver", + 'BaseLabelPrintingDriver', ] diff --git a/InvenTree/plugin/machine/__init__.py b/InvenTree/plugin/machine/__init__.py index b6c92a2dff1..df104dd6929 100644 --- a/InvenTree/plugin/machine/__init__.py +++ b/InvenTree/plugin/machine/__init__.py @@ -1,11 +1,10 @@ -from machine import (BaseDriver, BaseMachineType, MachineStatus, machine_types, - registry) +from machine import BaseDriver, BaseMachineType, MachineStatus, machine_types, registry from machine.machine_types import * # noqa: F403, F401 __all__ = [ - "registry", - "BaseDriver", - "BaseMachineType", - "MachineStatus", - *machine_types.__all__ + 'registry', + 'BaseDriver', + 'BaseMachineType', + 'MachineStatus', + *machine_types.__all__, ] From 34d730febf68dc5e0851837955797e1866042f19 Mon Sep 17 00:00:00 2001 From: wolflu05 <76838159+wolflu05@users.noreply.github.com> Date: Sun, 28 Jan 2024 12:17:40 +0000 Subject: [PATCH 52/86] fix: style --- InvenTree/InvenTree/helpers_mixin.py | 23 ++++++++++++- InvenTree/machine/admin.py | 4 ++- InvenTree/machine/api.py | 20 ++++++------ InvenTree/machine/apps.py | 6 +++- InvenTree/machine/machine_type.py | 32 ++++++++++++++++--- .../machine_types/LabelPrintingMachineType.py | 6 ++++ InvenTree/machine/models.py | 25 +++++++++++---- InvenTree/machine/registry.py | 17 ++++++++-- InvenTree/machine/serializers.py | 22 ++++++++++++- InvenTree/machine/tests.py | 2 ++ InvenTree/machine/views.py | 3 -- 11 files changed, 131 insertions(+), 29 deletions(-) delete mode 100755 InvenTree/machine/views.py diff --git a/InvenTree/InvenTree/helpers_mixin.py b/InvenTree/InvenTree/helpers_mixin.py index 12e0911c67c..bdf8adf7224 100644 --- a/InvenTree/InvenTree/helpers_mixin.py +++ b/InvenTree/InvenTree/helpers_mixin.py @@ -14,6 +14,23 @@ class ClassValidationMixin: Class attributes: required_attributes: List of class attributes that need to be defined required_overrides: List of functions that need override + + Example: + ```py + class Parent(ClassValidationMixin): + NAME: str + def test(self): + pass + + required_attributes = ["NAME"] + required_overrides = [test] + + class MyClass(Parent): + pass + + myClass = MyClass() + myClass.validate() # raises NotImplementedError + ``` """ required_attributes = [] @@ -21,10 +38,14 @@ class ClassValidationMixin: @classmethod def validate(cls): + """Validate the class against the required attributes/overrides.""" + def attribute_missing(key): + """Check if attribute is missing.""" return not hasattr(cls, key) or getattr(cls, key) == '' def override_missing(base_implementation): + """Check if override is missing.""" return base_implementation == getattr( cls, base_implementation.__name__, None ) @@ -40,7 +61,7 @@ def override_missing(base_implementation): ) if len(missing_overrides) > 0: errors.append( - f"did not override the required attributes: {', '.join(map(lambda attr: attr.__name__, missing_overrides))}" + f"did not override the required attributes: {', '.join(attr.__name__ for attr in missing_overrides)}" ) if len(errors) > 0: diff --git a/InvenTree/machine/admin.py b/InvenTree/machine/admin.py index 5730415a517..3437c7a38ab 100755 --- a/InvenTree/machine/admin.py +++ b/InvenTree/machine/admin.py @@ -1,3 +1,5 @@ +"""Django admin interface for the machine app.""" + from django.contrib import admin from django.http.request import HttpRequest @@ -39,7 +41,7 @@ class MachineConfigAdmin(admin.ModelAdmin): inlines = [MachineSettingInline] def get_readonly_fields(self, request, obj): - # if update, don't allow changes on machine_type and driver + """If update, don't allow changes on machine_type and driver.""" if obj is not None: return ['machine_type', 'driver', *self.readonly_fields] diff --git a/InvenTree/machine/api.py b/InvenTree/machine/api.py index d6842ffeae5..9448d2ea993 100644 --- a/InvenTree/machine/api.py +++ b/InvenTree/machine/api.py @@ -1,3 +1,5 @@ +"""JSON API for the machine app.""" + from django.urls import include, path, re_path from drf_spectacular.utils import extend_schema @@ -24,7 +26,7 @@ class MachineList(ListCreateAPI): serializer_class = MachineSerializers.MachineConfigSerializer def get_serializer_class(self): - # allow driver, machine_type fields on creation + """Allow driver, machine_type fields on creation.""" if self.request.method == 'POST': return MachineSerializers.MachineConfigCreateSerializer return super().get_serializer_class() @@ -52,9 +54,6 @@ class MachineDetail(RetrieveUpdateDestroyAPI): queryset = MachineConfig.objects.all() serializer_class = MachineSerializers.MachineConfigSerializer - def update(self, request, *args, **kwargs): - return super().update(request, *args, **kwargs) - def get_machine(machine_pk): """Get machine by pk. @@ -85,6 +84,7 @@ class MachineSettingList(APIView): responses={200: MachineSerializers.MachineSettingSerializer(many=True)} ) def get(self, request, pk): + """Return all settings for a machine config.""" machine = get_machine(pk) all_settings = [] @@ -125,7 +125,7 @@ def get_object(self): machine = get_machine(pk) - setting_map = dict((d, s) for s, d in machine.setting_types) + setting_map = {d: s for s, d in machine.setting_types} if key.upper() not in setting_map[config_type]: raise NotFound( detail=f"Machine '{machine.name}' has no {config_type.name} setting matching '{key.upper()}'" @@ -139,13 +139,14 @@ def get_object(self): class MachineRestart(APIView): """Endpoint for performing a machine restart. - - POST: restart machine + - POST: restart machine by pk """ permission_classes = [permissions.IsAuthenticated] @extend_schema(responses={200: MachineSerializers.MachineRestartSerializer()}) def post(self, request, pk): + """Restart machine by pk.""" machine = get_machine(pk) registry.restart_machine(machine) @@ -163,6 +164,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()) results = MachineSerializers.MachineTypeSerializer( machine_types, many=True @@ -182,6 +184,7 @@ class MachineDriverList(APIView): responses={200: MachineSerializers.MachineDriverSerializer(many=True)} ) 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) @@ -206,10 +209,9 @@ class RegistryStatusView(APIView): responses={200: MachineSerializers.MachineRegistryStatusSerializer()} ) def get(self, request): + """Provide status data for the machine registry.""" result = MachineSerializers.MachineRegistryStatusSerializer({ - 'registry_errors': list( - {'message': str(error)} for error in registry.errors - ) + 'registry_errors': [{'message': str(error)} for error in registry.errors] }).data return Response(result) diff --git a/InvenTree/machine/apps.py b/InvenTree/machine/apps.py index 052df1b15a5..602c2500b02 100755 --- a/InvenTree/machine/apps.py +++ b/InvenTree/machine/apps.py @@ -1,3 +1,5 @@ +"""Django machine app config.""" + import logging from django.apps import AppConfig @@ -8,10 +10,12 @@ class MachineConfig(AppConfig): + """AppConfig class for the machine app.""" + name = 'machine' def ready(self) -> None: - """Initialization method for the Machine app.""" + """Initialization method for the machine app.""" if ( not canAppAccessDatabase(allow_test=True) or not isPluginRegistryLoaded() diff --git a/InvenTree/machine/machine_type.py b/InvenTree/machine/machine_type.py index b9a90b08f1a..eb864ac18d9 100644 --- a/InvenTree/machine/machine_type.py +++ b/InvenTree/machine/machine_type.py @@ -1,3 +1,5 @@ +"""Base machine type/base driver.""" + from typing import TYPE_CHECKING, Any, Dict, List, Literal, Tuple, Type, Union from generic.states import StatusCode @@ -10,9 +12,13 @@ else: # pragma: no cover class MachineConfig: + """Only used if not typechecking currently.""" + pass class SettingsKeyType: + """Only used if not typechecking currently.""" + pass @@ -40,7 +46,7 @@ class MachineStatus(StatusCode): class BaseDriver(ClassValidationMixin, ClassProviderMixin): - """Base class for machine drivers + """Base class for machine drivers. Attributes: SLUG: Slug string for identifying a machine @@ -61,6 +67,7 @@ class BaseDriver(ClassValidationMixin, ClassProviderMixin): required_attributes = ['SLUG', 'NAME', 'DESCRIPTION', 'machine_type'] def __init__(self) -> None: + """Base driver __init__ method.""" super().__init__() self.errors: list[Union[str, Exception]] = [] @@ -87,7 +94,7 @@ def init_machine(self, machine: 'BaseMachineType'): def update_machine( self, old_machine_state: Dict[str, Any], machine: 'BaseMachineType' ): - """This method gets called for each update of a machine + """This method gets called for each update of a machine. Note: machine.restart_required can be set to True here if the machine needs a manual restart to apply the changes @@ -110,17 +117,27 @@ def restart_machine(self, machine: 'BaseMachineType'): pass def get_machines(self, **kwargs): - """Return all machines using this driver. (By default only initialized machines)""" + """Return all machines using this driver (By default only initialized machines). + + Kwargs: + name: Machine name + machine_type: Machine type definition (class) + driver: Machine driver (class) + initialized: (bool, default: True) + active: (bool) + base_driver: base driver (class) + """ from machine import registry return registry.get_machines(driver=self, **kwargs) def handle_error(self, error: Union[Exception, str]): + """Handle driver error.""" self.errors.append(error) class BaseMachineType(ClassValidationMixin, ClassProviderMixin): - """Base class for machine types + """Base class for machine types. Attributes: SLUG: Slug string for identifying a machine type @@ -157,6 +174,7 @@ class BaseMachineType(ClassValidationMixin, ClassProviderMixin): ] def __init__(self, machine_config: MachineConfig) -> None: + """Base machine type __init__ function.""" from machine import registry from machine.models import MachineSetting @@ -193,11 +211,13 @@ def __init__(self, machine_config: MachineConfig) -> None: self.restart_required = False def __str__(self): + """String representation of a machine.""" return f'{self.name}' # --- properties @property def machine_config(self): + """Machine_config property.""" # always fetch the machine_config if needed to ensure we get the newest reference from .models import MachineConfig @@ -205,15 +225,17 @@ def machine_config(self): @property def name(self): + """Name property.""" return self.machine_config.name @property def active(self): + """Active property.""" return self.machine_config.active # --- hook functions def initialize(self): - """Machine initialization function, gets called after all machines are loaded""" + """Machine initialization function, gets called after all machines are loaded.""" if self.driver is None: return diff --git a/InvenTree/machine/machine_types/LabelPrintingMachineType.py b/InvenTree/machine/machine_types/LabelPrintingMachineType.py index e1160e01c8b..0b44e8dca4d 100644 --- a/InvenTree/machine/machine_types/LabelPrintingMachineType.py +++ b/InvenTree/machine/machine_types/LabelPrintingMachineType.py @@ -1,3 +1,5 @@ +"""Label printing machine type.""" + from django.utils.translation import gettext_lazy as _ from machine.machine_type import BaseDriver, BaseMachineType, MachineStatus @@ -20,6 +22,8 @@ def print_labels(self): class LabelPrintingMachineType(BaseMachineType): + """Label printer machine type, is a direct integration to print labels for various items.""" + SLUG = 'label_printer' NAME = _('Label Printer') DESCRIPTION = _('Directly print labels for various items.') @@ -27,6 +31,8 @@ class LabelPrintingMachineType(BaseMachineType): base_driver = BaseLabelPrintingDriver class LabelPrinterStatus(MachineStatus): + """Label printer status codes.""" + CONNECTED = 100, _('Connected'), 'success' STANDBY = 101, _('Standby'), 'success' PRINTING = 110, _('Printing'), 'primary' diff --git a/InvenTree/machine/models.py b/InvenTree/machine/models.py index a33e1c9b35a..d8f570d2fb6 100755 --- a/InvenTree/machine/models.py +++ b/InvenTree/machine/models.py @@ -1,3 +1,5 @@ +"""Models for the machine app.""" + import uuid from typing import Literal @@ -42,6 +44,7 @@ def __str__(self) -> str: return f'{self.name}' def save(self, *args, **kwargs) -> None: + """Custom save function to capture creates/updates to notify the registry.""" created = self._state.adding old_machine = None @@ -64,13 +67,14 @@ def save(self, *args, **kwargs) -> None: registry.update_machine(old_machine, self) def delete(self, *args, **kwargs): - # remove machine from registry first + """Remove machine from registry first.""" if self.machine: registry.remove_machine(self.machine) return super().delete(*args, **kwargs) def to_dict(self): + """Serialize a machine config to a dict including setting.""" machine = {f.name: f.value_to_string(self) for f in self._meta.fields} machine['settings'] = { (setting.config_type, setting.key): setting.value @@ -80,35 +84,39 @@ def to_dict(self): @property def machine(self): + """Machine instance getter.""" return registry.get_machine(self.pk) @property def errors(self): + """Machine errors getter.""" return getattr(self.machine, 'errors', []) @admin.display(boolean=True, description=_('Driver available')) def is_driver_available(self) -> bool: - """Status if driver for machine is available""" + """Status if driver for machine is available.""" return self.machine is not None and self.machine.driver is not None @admin.display(boolean=True, description=_('No errors')) def no_errors(self) -> bool: - """Status if machine has errors""" + """Status if machine has errors.""" return len(self.errors) == 0 @admin.display(boolean=True, description=_('Initialized')) def initialized(self) -> bool: - """Status if machine is initialized""" + """Status if machine is initialized.""" return getattr(self.machine, 'initialized', False) @admin.display(description=_('Errors')) def get_admin_errors(self): + """Get machine errors for django admin interface.""" return format_html_join( mark_safe('
'), '{}', ((str(error),) for error in self.errors) ) or mark_safe(f"{_('No errors')}") @admin.display(description=_('Machine status')) def get_machine_status(self): + """Get machine status for django admin interface.""" if self.machine is None: return None @@ -132,6 +140,8 @@ class Meta: unique_together = [('machine_config', 'config_type', 'key')] class ConfigType(models.TextChoices): + """Machine setting config type enum.""" + MACHINE = 'M', _('Machine') DRIVER = 'D', _('Driver') @@ -147,6 +157,7 @@ class ConfigType(models.TextChoices): ) def save(self, *args, **kwargs) -> None: + """Custom save method to notify the registry on changes.""" old_machine = self.machine_config.to_dict() super().save(*args, **kwargs) @@ -155,6 +166,7 @@ def save(self, *args, **kwargs) -> None: @classmethod def get_config_type(cls, config_type_str: Literal['M', 'D']): + """Helper method to get the correct enum value for easier usage with literal strings.""" if config_type_str == 'M': return cls.ConfigType.MACHINE elif config_type_str == 'D': @@ -162,8 +174,9 @@ def get_config_type(cls, config_type_str: Literal['M', 'D']): @classmethod def get_setting_definition(cls, key, **kwargs): - """In the BaseInvenTreeSetting class, we have a class attribute named 'SETTINGS', which - is a dict object that fully defines all the setting parameters. + """In the BaseInvenTreeSetting class, we have a class attribute named 'SETTINGS'. + + which is a dict object that fully defines all the setting parameters. Here, unlike the BaseInvenTreeSetting, we do not know the definitions of all settings 'ahead of time' (as they are defined externally in the machine driver). diff --git a/InvenTree/machine/registry.py b/InvenTree/machine/registry.py index 4b308e77d33..4798e31b450 100644 --- a/InvenTree/machine/registry.py +++ b/InvenTree/machine/registry.py @@ -1,3 +1,5 @@ +"""Machine registry.""" + import logging from typing import Dict, List, Set, Type, Union from uuid import UUID @@ -8,8 +10,10 @@ class MachineRegistry: + """Machine registry class.""" + def __init__(self) -> None: - """Initialize machine registry + """Initialize machine registry. Set up all needed references for internal and external states. """ @@ -26,11 +30,13 @@ def handle_error(self, error: Union[Exception, str]): self.errors.append(error) def initialize(self): + """Initialize the machine registry.""" self.discover_machine_types() self.discover_drivers() self.load_machines() def discover_machine_types(self): + """Discovers all machine types by inferring all classes that inherit the BaseMachineType class.""" import InvenTree.helpers logger.debug('Collecting machine types') @@ -63,6 +69,7 @@ def discover_machine_types(self): logger.debug(f'Found {len(self.machine_types.keys())} machine types') def discover_drivers(self): + """Discovers all machine drivers by inferring all classes that inherit the BaseDriver class.""" import InvenTree.helpers logger.debug('Collecting machine drivers') @@ -96,6 +103,7 @@ def discover_drivers(self): logger.debug(f'Found {len(self.drivers.keys())} machine drivers') def get_driver_instance(self, slug: str): + """Return or create a driver instance if needed.""" if slug not in self.driver_instances: driver = self.drivers.get(slug, None) if driver is None: @@ -106,6 +114,7 @@ def get_driver_instance(self, slug: str): return self.driver_instances.get(slug, None) def load_machines(self): + """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 @@ -122,6 +131,7 @@ def load_machines(self): machine.initialize() def add_machine(self, machine_config, initialize=True): + """Add a machine to the machine registry.""" machine_type = self.machine_types.get(machine_config.machine_type, None) if machine_type is None: self.handle_error(f"Machine type '{machine_config.machine_type}' not found") @@ -134,17 +144,20 @@ def add_machine(self, machine_config, initialize=True): machine.initialize() def update_machine(self, old_machine_state, machine_config): + """Notify the machine about an update.""" if machine := machine_config.machine: machine.update(old_machine_state) def restart_machine(self, machine): + """Restart a machine.""" machine.restart() def remove_machine(self, machine: BaseMachineType): + """Remove a machine from the registry.""" self.machines.pop(str(machine.pk), None) def get_machines(self, **kwargs): - """Get loaded machines from registry. (By default only initialized machines) + """Get loaded machines from registry (By default only initialized machines). Kwargs: name: Machine name diff --git a/InvenTree/machine/serializers.py b/InvenTree/machine/serializers.py index ba5983ee1c9..02df3f6caa9 100644 --- a/InvenTree/machine/serializers.py +++ b/InvenTree/machine/serializers.py @@ -1,3 +1,5 @@ +"""Serializers for the machine app.""" + from typing import List, Union from rest_framework import serializers @@ -41,29 +43,36 @@ class Meta: restart_required = serializers.SerializerMethodField('get_restart_required') def get_initialized(self, obj: MachineConfig) -> bool: + """Serializer method for the initialized field.""" return getattr(obj.machine, 'initialized', False) def get_status(self, obj: MachineConfig) -> int: + """Serializer method for the status field.""" status = getattr(obj.machine, 'status', None) if status is not None: return status.value return -1 def get_status_model(self, obj: MachineConfig) -> Union[str, None]: + """Serializer method for the status model field.""" if obj.machine and obj.machine.MACHINE_STATUS: return obj.machine.MACHINE_STATUS.__name__ return None def get_status_text(self, obj: MachineConfig) -> str: + """Serializer method for the status text field.""" return getattr(obj.machine, 'status_text', '') def get_errors(self, obj: MachineConfig) -> List[str]: + """Serializer method for the errors field.""" return [str(err) for err in obj.errors] def get_is_driver_available(self, obj: MachineConfig) -> bool: + """Serializer method for the is_driver_available field.""" return obj.is_driver_available() def get_restart_required(self, obj: MachineConfig) -> bool: + """Serializer method for the restart_required field.""" return getattr(obj.machine, 'restart_required', False) @@ -75,7 +84,7 @@ class Meta(MachineConfigSerializer.Meta): read_only_fields = list( set(MachineConfigSerializer.Meta.read_only_fields) - - set(['machine_type', 'driver']) + - {'machine_type', 'driver'} ) @@ -86,6 +95,7 @@ class MachineSettingSerializer(GenericReferencedSettingSerializer): EXTRA_FIELDS = ['config_type'] def __init__(self, *args, **kwargs): + """Custom init method to remove unwanted fields.""" super().__init__(*args, **kwargs) # remove unwanted fields @@ -122,9 +132,11 @@ class Meta: is_builtin = serializers.SerializerMethodField('get_is_builtin') def get_provider_file(self, obj: ClassProviderMixin) -> str: + """Serializer method for the provider_file field.""" return obj.get_provider_file() def get_provider_plugin(self, obj: ClassProviderMixin) -> Union[dict, None]: + """Serializer method for the provider_plugin field.""" plugin = obj.get_provider_plugin() if plugin: return { @@ -135,6 +147,7 @@ def get_provider_plugin(self, obj: ClassProviderMixin) -> Union[dict, None]: return None def get_is_builtin(self, obj: ClassProviderMixin) -> bool: + """Serializer method for the is_builtin field.""" return obj.get_is_builtin() @@ -160,6 +173,7 @@ class Meta(BaseMachineClassSerializer.Meta): driver_errors = serializers.SerializerMethodField('get_errors') def get_errors(self, obj) -> List[str]: + """Serializer method for the errors field.""" driver_instance = registry.driver_instances.get(obj.SLUG, None) if driver_instance is None: return [] @@ -170,6 +184,8 @@ class MachineRegistryErrorSerializer(serializers.Serializer): """Serializer for a machine registry error.""" class Meta: + """Meta for a serializer.""" + fields = ['message'] message = serializers.CharField() @@ -179,6 +195,8 @@ class MachineRegistryStatusSerializer(serializers.Serializer): """Serializer for machine registry status.""" class Meta: + """Meta for a serializer.""" + fields = ['registry_errors'] registry_errors = serializers.ListField(child=MachineRegistryErrorSerializer()) @@ -188,6 +206,8 @@ class MachineRestartSerializer(serializers.Serializer): """Serializer for the machine restart response.""" class Meta: + """Meta for a serializer.""" + fields = ['ok'] ok = serializers.BooleanField() diff --git a/InvenTree/machine/tests.py b/InvenTree/machine/tests.py index a79ca8be565..413d0d4bc7e 100755 --- a/InvenTree/machine/tests.py +++ b/InvenTree/machine/tests.py @@ -1,3 +1,5 @@ +"""Machine app tests.""" + # from django.test import TestCase # Create your tests here. diff --git a/InvenTree/machine/views.py b/InvenTree/machine/views.py deleted file mode 100755 index fd0e0449559..00000000000 --- a/InvenTree/machine/views.py +++ /dev/null @@ -1,3 +0,0 @@ -# from django.shortcuts import render - -# Create your views here. From e36451cb786076b510a238b350ce2bffaaaa4700 Mon Sep 17 00:00:00 2001 From: wolflu05 <76838159+wolflu05@users.noreply.github.com> Date: Sun, 28 Jan 2024 12:26:10 +0000 Subject: [PATCH 53/86] Fix deepsource --- InvenTree/machine/registry.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/InvenTree/machine/registry.py b/InvenTree/machine/registry.py index 4798e31b450..76f4142f104 100644 --- a/InvenTree/machine/registry.py +++ b/InvenTree/machine/registry.py @@ -66,7 +66,7 @@ def discover_machine_types(self): self.machine_types = machine_types self.base_drivers = base_drivers - logger.debug(f'Found {len(self.machine_types.keys())} machine types') + 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.""" @@ -100,7 +100,7 @@ def discover_drivers(self): self.drivers = drivers - logger.debug(f'Found {len(self.drivers.keys())} machine drivers') + logger.debug('Found %s machine drivers', len(self.drivers.keys())) def get_driver_instance(self, slug: str): """Return or create a driver instance if needed.""" @@ -130,6 +130,8 @@ def load_machines(self): if machine.active: machine.initialize() + logger.info('Initialized %s machines', len(self.machines.keys())) + def add_machine(self, machine_config, initialize=True): """Add a machine to the machine registry.""" machine_type = self.machine_types.get(machine_config.machine_type, None) From 9bf51fe67cb7672cb609bcf84c136bdb5f4694ce Mon Sep 17 00:00:00 2001 From: wolflu05 <76838159+wolflu05@users.noreply.github.com> Date: Sun, 28 Jan 2024 17:35:43 +0000 Subject: [PATCH 54/86] Removed slug field from table, added more links between drawers, remove detail drawer blur --- .../src/components/nav/DetailDrawer.tsx | 1 - .../tables/machine/MachineTypeTable.tsx | 51 ++++++++++--------- 2 files changed, 26 insertions(+), 26 deletions(-) diff --git a/src/frontend/src/components/nav/DetailDrawer.tsx b/src/frontend/src/components/nav/DetailDrawer.tsx index 8c341ef1ef5..80c4e2e3b72 100644 --- a/src/frontend/src/components/nav/DetailDrawer.tsx +++ b/src/frontend/src/components/nav/DetailDrawer.tsx @@ -53,7 +53,6 @@ function DetailDrawerComponent({
} - overlayProps={{ opacity: 0.5, blur: 4 }} > diff --git a/src/frontend/src/components/tables/machine/MachineTypeTable.tsx b/src/frontend/src/components/tables/machine/MachineTypeTable.tsx index b4684c93a27..f5ca4363106 100644 --- a/src/frontend/src/components/tables/machine/MachineTypeTable.tsx +++ b/src/frontend/src/components/tables/machine/MachineTypeTable.tsx @@ -64,10 +64,6 @@ function MachineTypeDrawer({ machineTypeSlug }: { machineTypeSlug: string }) { accessor: 'name', title: t`Name` }, - { - accessor: 'slug', - title: t`Slug` - }, { accessor: 'description', title: t`Description` @@ -114,16 +110,18 @@ function MachineTypeDrawer({ machineTypeSlug }: { machineTypeSlug: string }) { value={machineType?.description} type="text" /> - + {!machineType?.is_builtin && ( + + )} - + {!machineDriver?.is_builtin && ( + + )} Date: Sun, 28 Jan 2024 20:56:22 +0000 Subject: [PATCH 55/86] Added initial docs --- docs/docs/extend/plugins/machines.md | 204 +++++++++++++++++++++++++++ docs/mkdocs.yml | 1 + 2 files changed, 205 insertions(+) create mode 100644 docs/docs/extend/plugins/machines.md diff --git a/docs/docs/extend/plugins/machines.md b/docs/docs/extend/plugins/machines.md new file mode 100644 index 00000000000..32db0666f38 --- /dev/null +++ b/docs/docs/extend/plugins/machines.md @@ -0,0 +1,204 @@ +--- +title: Machines +--- + +## 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. + +### Registry + +The machine registry is the main component which gets initialized on server start and manages all configured machines. + +#### Initialization process + +The machine registry initialization process can be divided into three stages as described in this diagram: + +```mermaid +flowchart LR + A["`**Server start**`"] --> B + B["`**Stage 1: Discover machine types** + by looking for classes that inherit the BaseMachineType class`"] --> C + C["`**Stage 2: Discover drivers** + by looking for classes that inherit the BaseDriver class (and are not referenced as base driver for any discovered machine type)`"] --> MA + subgraph MA["Stage 3: Machine loading"] + direction TB + D["`For each MachineConfig in database instantiate the MachineType class (drivers get instantiated here as needed and passed to the machine class. But only one instance of the driver class is maintained along the registry)`"] --> E + E["`The driver.init_driver function is called for each used driver`"] --> F + F["`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`"] + end + MA --> X["`**Done**`"] +``` + +### 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. +The machine type class gets instantiated for each machine on server startup and the reference is stored in the machine registry. + +#### Built in types + +| Name | Description | +| ------------------------------- | ---------------------------------------- | +| [Label printer](#label-printer) | Directly print labels for various items. | + +##### Label printer + +Label printer machines can directly print labels for various items in InvenTree. They replace standard [`LabelPrintingMixin`](../plugins/label.md) plugins that are used to connect to physical printers. Using machines rather than a standard `LabelPrintingMixin` plugin has the advantage that machines can be created multiple times using different settings but the same driver. That way multiple label printers of the same brand can be connected. + +TODO + +#### Available attributes + +| Name | Description | +| ------------------------ | --------------------------------------------------------------------------------------------------------------------- | +| `SLUG` | A slug for the machine type needs to be set (short identifier string that satisfies the following format `/[a-z-]+/`) | +| `NAME` | A name for the machine type needs to be set | +| `DESCRIPTION` | A description for the machine type needs to be set | +| `base_driver` | Reference to the base driver class | +| `MACHINE_SETTINGS` | Machine settings dict, see [settings](#settings) | +| `MACHINE_STATUS` | Machine status enum, see [status](#machine-status) | +| `default_machine_status` | default machine status, see [status](#machine-status) | + +#### Available methods + +| Name | Description | +| ------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------- | +| `initialize(self)` | gets called on initialization | +| `update(self, old_machine_state: Dict[str, Any])` | gets called if any changes on a machine or their settings occur. The `machine.restart_required` flag can be set here. | +| `restart(self, machine: 'BaseMachineType')` | gets called if the admin manually presses the restart button on the admin interface | +| `handle_error(self, error: Union[Exception, str])` | helper function to capture errors and show them in the admin ui | +| `get_setting(self, key: str, config_type_str: Literal['M', 'D'], cache=False)` | get a setting for a machine | +| `set_setting(self, key: str, config_type_str: Literal['M', 'D'], value)` | set a setting for a machine | +| `check_settings(self)` | check that all required settings are set | +| `set_status(self, status: MachineStatus)` | set a machine status code | +| `set_status_text(self, status_text: str)` | set a machine status text | + +#### Example machine type + +If you want to create your own machine type, please also take a look at the already existing machine types. + +```py +from django.utils.translation import ugettext_lazy as _ +from machine.machine_type import BaseDriver, BaseMachineType, MachineStatus + +class BaseXYZDriver(BaseDriver): + """Base xyz driver.""" + + machine_type = 'xyz' + + def my_custom_required_method(self): + """This function must be overridden.""" + raise NotImplementedError('The `my_custom_required_method` function must be overridden!') + + def my_custom_method(self): + """This function must be overridden.""" + raise NotImplementedError('The `my_custom_method` function must be overridden!') + + requires_override = [my_custom_required_method] + +class XYZMachineType(BaseMachineType): + SLUG = 'xyz' + NAME = _('XYZ') + DESCRIPTION = _('This is an awesome machine type for xyz.') + + base_driver = BaseXYZDriver + + class XYZStatus(MachineStatus): + CONNECTED = 100, _('Connected'), 'success' + STANDBY = 101, _('Standby'), 'success' + PRINTING = 110, _('Printing'), 'primary' + + MACHINE_STATUS = XYZStatus + + default_machine_status = XYZStatus.DISCONNECTED +``` + +### Drivers + +Drivers provide the connection layer between physical machines and inventree. There can be multiple drivers defined for the same machine type. Drivers are provided by plugins that are enabled and extend the corresponding base driver for the particular machine type. Each machine type already provides a base driver that needs to be inherited. + +#### Available attributes + +| Name | Description | +| ------------------ | --------------------------------------------------------------------------------------------------------------- | +| `SLUG` | A slug for the driver needs to be set (short identifier string that satisfies the following format `/[a-z-]+/`) | +| `NAME` | A name for the driver needs to be set | +| `DESCRIPTION` | A description for the driver needs to be set | +| `MACHINE_SETTINGS` | Machine settings dict, see [settings](#settings) (optional) | +| (`machine_type`) | Already set to the machine type slug by the base driver | + +#### Available methods + +| Name | Description | +| ------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | +| `init_driver(self)` | gets called on initialization before each individual machine gets initialized | +| `init_machine(self, machine: 'BaseMachineType')` | gets called for each machine | +| `update_machine(self, old_machine_state: Dict[str, Any], machine: 'BaseMachineType')` | gets called if any changes on a machine or their settings occur. The `machine.restart_required` flag can be set here. | +| `restart_machine(self, machine: 'BaseMachineType')` | gets called if the admin manually presses the restart button on the admin interface | +| `get_machines(self, *, name, machine_type, driver, initialized, active, base_driver)` | helper function to get all machine, by default only initialized machines that also use the current driver are returned | +| `handle_error(self, error: Union[Exception, str])` | helper function to capture errors and show them in the admin ui | + +#### Example driver + +A basic driver only needs to specify the basic attributes like `SLUG`, `NAME`, `DESCRIPTION`. The others are given by the used base driver, so take a look at [Machine types](#machine-types). The class will be discovered if it is provided by an **installed & activated** plugin just like this: + +```py +from plugin import InvenTreePlugin +from plugin.machine import BaseXYZDriver + +class CupsLabelPlugin(InvenTreePlugin): + NAME = "CupsLabels" + SLUG = "cups" + TITLE = "Cups Label Printer" + # ... + +class MyXYZDriver(BaseXYZDriver): + SLUG = 'my-abc-driver' + NAME = 'My ABC driver' + DESCRIPTION = 'This is an awesome driver for ABC' +``` + +### Settings + +Each machine can have different settings configured. There are machine settings that are specific to that machine type and driver settings that are specific to the driver, but both can be specified individually for each machine. Define them by adding a `MACHINE_SETTINGS` dictionary attribute to either the driver or the machine type. The format follows the same pattern as the `SETTINGS` for normal plugins documented on the [`SettingsMixin`](../plugins/settings.md) + +```py +class MyXYZDriver(BaseXYZDriver): + MACHINE_SETTINGS = { + 'SERVER': { + 'name': _('Server'), + 'description': _('IP/Hostname to connect to the cups server'), + 'default': 'localhost', + 'required': True, + } + } +``` + +Settings can even marked as `'required': True` which prevents the machine from starting if the setting is not defined. + +### Machine status + +Each machine type has a set of status codes defined that can be set for each machine by the driver. There also needs to be a default status code defined. + +```py +class XYZMachineType(BaseMachineType): + # ... + class XYZStatus(MachineStatus): + CONNECTED = 100, _('Connected'), 'success' + STANDBY = 101, _('Standby'), 'success' + DISCONNECTED = 400, _('Disconnected'), 'danger' + + MACHINE_STATUS = XYZStatus + default_machine_status = XYZStatus.DISCONNECTED +``` + +And to set a status code for a machine by the driver. There can also be a free text status code defined. + +```py +class MyXYZDriver(BaseXYZDriver): + # ... + def init_machine(self, machine): + # ... do some init stuff here + machine.set_status(XYZMachineType.MACHINE_STATUS.CONNECTED) + machine.set_status_text("Paper missing") +``` diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 2afbf527ae6..4ad2dffab96 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -201,6 +201,7 @@ nav: - Developing a Plugin: extend/how_to_plugin.md - Model Metadata: extend/plugins/metadata.md - Tags: extend/plugins/tags.md + - Machines: extend/plugins/machines.md - Plugin Mixins: - Action Mixin: extend/plugins/action.md - API Mixin: extend/plugins/api.md From d27ac8db62824ed18ddfa24c42034699c4295a3c Mon Sep 17 00:00:00 2001 From: wolflu05 <76838159+wolflu05@users.noreply.github.com> Date: Fri, 2 Feb 2024 09:21:53 +0000 Subject: [PATCH 56/86] Removed description from driver/machine type select and fixed disabled driver select if no machine type is selected --- src/frontend/src/components/forms/fields/ChoiceField.tsx | 1 + src/frontend/src/tables/machine/MachineListTable.tsx | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/frontend/src/components/forms/fields/ChoiceField.tsx b/src/frontend/src/components/forms/fields/ChoiceField.tsx index 8eedfb2bd13..d0137d9d3b0 100644 --- a/src/frontend/src/components/forms/fields/ChoiceField.tsx +++ b/src/frontend/src/components/forms/fields/ChoiceField.tsx @@ -62,6 +62,7 @@ export function ChoiceField({ description={definition.description} placeholder={definition.placeholder} required={definition.required} + disabled={definition.disabled} icon={definition.icon} withinPortal={true} /> diff --git a/src/frontend/src/tables/machine/MachineListTable.tsx b/src/frontend/src/tables/machine/MachineListTable.tsx index b6efbfb8182..ea8fed09629 100644 --- a/src/frontend/src/tables/machine/MachineListTable.tsx +++ b/src/frontend/src/tables/machine/MachineListTable.tsx @@ -485,7 +485,7 @@ export function MachineListTable({ .filter((d) => d.machine_type === createFormMachineType) .map((d) => ({ value: d.slug, - display_name: `${d.name} (${d.description})` + display_name: d.name })); }, [machineDrivers, createFormMachineType]); @@ -503,7 +503,7 @@ export function MachineListTable({ choices: machineTypes ? machineTypes.map((t) => ({ value: t.slug, - display_name: `${t.name} (${t.description})` + display_name: t.name })) : [], onValueChange: (value) => setCreateFormMachineType(value) From 24b4987222683c105609c0c32c2b0472d4a67e01 Mon Sep 17 00:00:00 2001 From: wolflu05 <76838159+wolflu05@users.noreply.github.com> Date: Fri, 2 Feb 2024 15:44:06 +0000 Subject: [PATCH 57/86] Added basic label printing implementation --- InvenTree/InvenTree/helpers_mixin.py | 16 +- InvenTree/machine/machine_type.py | 3 +- .../machine_types/LabelPrintingMachineType.py | 180 +++++++++++++++++- InvenTree/machine/registry.py | 8 + InvenTree/plugin/base/label/mixins.py | 9 +- .../builtin/labels/inventree_machine.py | 112 +++++++++++ 6 files changed, 314 insertions(+), 14 deletions(-) create mode 100644 InvenTree/plugin/builtin/labels/inventree_machine.py diff --git a/InvenTree/InvenTree/helpers_mixin.py b/InvenTree/InvenTree/helpers_mixin.py index bdf8adf7224..59c88785ed6 100644 --- a/InvenTree/InvenTree/helpers_mixin.py +++ b/InvenTree/InvenTree/helpers_mixin.py @@ -13,7 +13,7 @@ class ClassValidationMixin: Class attributes: required_attributes: List of class attributes that need to be defined - required_overrides: List of functions that need override + required_overrides: List of functions that need override, a nested list mean either one of them needs an override Example: ```py @@ -46,6 +46,9 @@ def attribute_missing(key): def override_missing(base_implementation): """Check if override is missing.""" + if isinstance(base_implementation, list): + return all(override_missing(x) for x in base_implementation) + return base_implementation == getattr( cls, base_implementation.__name__, None ) @@ -60,8 +63,17 @@ def override_missing(base_implementation): f"did not provide the following attributes: {', '.join(missing_attributes)}" ) if len(missing_overrides) > 0: + missing_overrides_list = [] + for base_implementation in missing_overrides: + if isinstance(base_implementation, list): + missing_overrides_list.append( + 'one of ' + + ' or '.join(attr.__name__ for attr in base_implementation) + ) + else: + missing_overrides_list.append(base_implementation.__name__) errors.append( - f"did not override the required attributes: {', '.join(attr.__name__ for attr in missing_overrides)}" + f"did not override the required attributes: {', '.join(missing_overrides_list)}" ) if len(errors) > 0: diff --git a/InvenTree/machine/machine_type.py b/InvenTree/machine/machine_type.py index eb864ac18d9..a217c952efa 100644 --- a/InvenTree/machine/machine_type.py +++ b/InvenTree/machine/machine_type.py @@ -122,13 +122,14 @@ def get_machines(self, **kwargs): Kwargs: name: Machine name machine_type: Machine type definition (class) - driver: Machine driver (class) initialized: (bool, default: True) active: (bool) base_driver: base driver (class) """ from machine import registry + kwargs.pop('driver', None) + return registry.get_machines(driver=self, **kwargs) def handle_error(self, error: Union[Exception, str]): diff --git a/InvenTree/machine/machine_types/LabelPrintingMachineType.py b/InvenTree/machine/machine_types/LabelPrintingMachineType.py index 0b44e8dca4d..873b1fe7d66 100644 --- a/InvenTree/machine/machine_types/LabelPrintingMachineType.py +++ b/InvenTree/machine/machine_types/LabelPrintingMachineType.py @@ -1,8 +1,19 @@ """Label printing machine type.""" +from typing import Union, cast + +from django.db.models.query import QuerySet +from django.http import HttpResponse, JsonResponse from django.utils.translation import gettext_lazy as _ +from PIL.Image import Image +from rest_framework import serializers +from rest_framework.request import Request + +from label.models import LabelTemplate from machine.machine_type import BaseDriver, BaseMachineType, MachineStatus +from plugin import registry as plg_registry +from plugin.base.label.mixins import LabelItemType, LabelPrintingMixin class BaseLabelPrintingDriver(BaseDriver): @@ -10,15 +21,166 @@ class BaseLabelPrintingDriver(BaseDriver): machine_type = 'label_printer' - def print_label(self): - """This function must be overridden.""" - raise NotImplementedError('The `print_label` function must be overridden!') - - def print_labels(self): - """This function must be overridden.""" - raise NotImplementedError('The `print_labels` function must be overridden!') - - requires_override = [print_label] + # Run printing functions by default in a background worker. + USE_BACKGROUND_WORKER = True + + def print_label( + self, + machine: 'LabelPrintingMachineType', + label: LabelTemplate, + item: LabelItemType, + request: Request, + **kwargs, + ) -> None: + """Print a single label with the provided template and item. + + Arguments: + 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: The printing options set for this print job defined in the PrintingOptionsSerializer + + Note that the supplied args/kwargs may be different if the driver overrides the print_labels() method. + """ + pass + + def print_labels( + self, + machine: 'LabelPrintingMachineType', + label: LabelTemplate, + items: QuerySet[LabelItemType], + request: Request, + **kwargs, + ) -> Union[None, JsonResponse]: + """Print one or more labels with the provided template and items. + + Arguments: + 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: The printing options set for this print job defined in the PrintingOptionsSerializer + + Returns: + If USE_BACKGROUND_WORKER=False, a JsonResponse object which indicates outcome to the user, otherwise None + + The default implementation simply calls print_label() for each label, producing multiple single label output "jobs" + but this can be overridden by the particular driver. + """ + for item in items: + self.print_label(machine, label, item, request, **kwargs) + + def get_printers( + self, label: LabelTemplate, items: QuerySet[LabelItemType], **kwargs + ) -> list[BaseMachineType]: + """Get all printers that would be available to print this job. + + By default all printers that are initialized using this driver are returned. + + Arguments: + label: The LabelTemplate object to use for printing + items: The lost of database items to print (e.g. StockItem instances) + """ + return self.get_machines() + + def get_printing_options_serializer( + self, request: Request, *args, **kwargs + ) -> Union[serializers.Serializer, None]: + """Return a serializer class instance with dynamic printing options. + + Arguments: + request: The request made to print a label or interfering the available serializer fields via an OPTIONS request + *args, **kwargs: need to be passed to the serializer instance + + Returns: + A class instance of a DRF serializer class, by default this an instance of + self.PrintingOptionsSerializer using the *args, **kwargs if existing for this driver + """ + serializer = getattr(self, 'PrintingOptionsSerializer', None) + + if not serializer: + return None + + return serializer(*args, **kwargs) + + # --- helper functions + @property + def machine_plugin(self) -> LabelPrintingMixin: + """Returns the builtin machine label printing plugin.""" + plg = plg_registry.get_plugin('inventreelabelmachine') + return cast(LabelPrintingMixin, plg) + + def render_to_pdf( + self, label: LabelTemplate, item: LabelItemType, request: Request, **kwargs + ) -> HttpResponse: + """Render this label to PDF format. + + 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 + """ + label.object_to_print = item + response = self.machine_plugin.render_to_pdf(label, request, **kwargs) + label.object_to_print = None + return response + + def render_to_pdf_data( + self, label: LabelTemplate, item: LabelItemType, request: Request, **kwargs + ) -> bytes: + """Render this label to PDF and return it as bytes. + + 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) + .get_document() # type: ignore + .write_pdf() + ) + + def render_to_html( + self, label: LabelTemplate, item: LabelItemType, request: Request, **kwargs + ) -> str: + """Render this label to HTML format. + + 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 + """ + label.object_to_print = item + html = self.machine_plugin.render_to_html(label, request, **kwargs) + label.object_to_print = None + return html + + def render_to_png( + self, label: LabelTemplate, item: LabelItemType, request: Request, **kwargs + ) -> Image: + """Render this label to PNG format. + + 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: The pdf document as bytes (optional) + dpi: The dpi used to render the image (optional) + """ + label.object_to_print = item + png = self.machine_plugin.render_to_png(label, request, **kwargs) + label.object_to_print = None + return png + + required_overrides = [[print_label, print_labels]] class LabelPrintingMachineType(BaseMachineType): diff --git a/InvenTree/machine/registry.py b/InvenTree/machine/registry.py index 76f4142f104..f949f299b88 100644 --- a/InvenTree/machine/registry.py +++ b/InvenTree/machine/registry.py @@ -209,5 +209,13 @@ def get_machine(self, pk: Union[str, UUID]): """Get machine from registry by pk.""" return self.machines.get(str(pk), None) + def get_drivers(self, machine_type: str): + """Get all drivers for a specific machine type.""" + return [ + driver + for driver in self.driver_instances.values() + if driver.machine_type == machine_type + ] + registry: MachineRegistry = MachineRegistry() diff --git a/InvenTree/plugin/base/label/mixins.py b/InvenTree/plugin/base/label/mixins.py index 975cf669857..daef8d590c2 100644 --- a/InvenTree/plugin/base/label/mixins.py +++ b/InvenTree/plugin/base/label/mixins.py @@ -2,17 +2,23 @@ from typing import Union +from django.db.models.query import QuerySet from django.http import JsonResponse import pdf2image from rest_framework import serializers from rest_framework.request import Request +from build.models import BuildLine from common.models import InvenTreeSetting from InvenTree.tasks import offload_task from label.models import LabelTemplate +from part.models import Part from plugin.base.label import label as plugin_label from plugin.helpers import MixinNotImplementedError +from stock.models import StockItem, StockLocation + +LabelItemType = Union[StockItem, StockLocation, Part, BuildLine] class LabelPrintingMixin: @@ -77,9 +83,8 @@ def render_to_png(self, label: LabelTemplate, request=None, **kwargs): def print_labels( self, label: LabelTemplate, - items: list, + items: QuerySet[LabelItemType], request: Request, - printing_options: dict, **kwargs, ): """Print one or more labels with the provided template and items. diff --git a/InvenTree/plugin/builtin/labels/inventree_machine.py b/InvenTree/plugin/builtin/labels/inventree_machine.py new file mode 100644 index 00000000000..02ae4779b84 --- /dev/null +++ b/InvenTree/plugin/builtin/labels/inventree_machine.py @@ -0,0 +1,112 @@ +"""Label printing plugin that provides support for printing using a label printer machine.""" + +from typing import cast + +from django.http import JsonResponse +from django.utils.translation import gettext_lazy as _ + +from rest_framework import serializers + +from InvenTree.serializers import DependentField +from InvenTree.tasks import offload_task +from label.models import LabelTemplate +from machine.machine_type import BaseMachineType +from machine.machine_types import BaseLabelPrintingDriver, LabelPrintingMachineType +from plugin import InvenTreePlugin +from plugin.machine import registry +from plugin.mixins import LabelPrintingMixin + + +def get_machine_and_driver(machine_pk: str): + """Get the driver by machine pk and ensure that it is a label printing driver.""" + machine = registry.get_machine(machine_pk) + + if machine is None: + return None, None + + if machine.SLUG != 'label_printer': + return None, None + + machine = cast(LabelPrintingMachineType, machine) + driver = machine.driver + + if driver is None: + return machine, None + + return machine, cast(BaseLabelPrintingDriver, driver) + + +class InvenTreeLabelPlugin(LabelPrintingMixin, InvenTreePlugin): + """Builtin plugin for machine label printing. + + This enables machines to print labels. + """ + + NAME = 'InvenTreeLabelMachine' + TITLE = _('InvenTree machine label printer') + DESCRIPTION = _('Provides support for printing using a machine') + VERSION = '1.0.0' + AUTHOR = _('InvenTree contributors') + + def print_labels(self, label: LabelTemplate, items, request, **kwargs): + """Print labels implementation that calls the correct machine driver print_labels method.""" + machine, driver = get_machine_and_driver( + kwargs['printing_options'].get('machine', '') + ) + + if driver is None or machine is None: + return None + + print_kwargs = { + **kwargs, + 'printing_options': kwargs['printing_options'].get('driver_options', {}), + } + + if driver.USE_BACKGROUND_WORKER is False: + return driver.print_labels(machine, label, items, request, **print_kwargs) + + offload_task( + driver.print_labels, machine, label, items, request, **print_kwargs + ) + + return JsonResponse({ + 'success': True, + 'message': f'{len(items)} labels printed', + }) + + class PrintingOptionsSerializer(serializers.Serializer): + """Printing options serializer that adds a machine select and the machines options.""" + + def __init__(self, *args, **kwargs): + """Custom __init__ method to dynamically override the machine choices based on the request.""" + super().__init__(*args, **kwargs) + + view = kwargs['context']['view'] + template = view.get_object() + items_to_print = view.get_items() + + machines: list[BaseMachineType] = [] + for driver in cast( + list[BaseLabelPrintingDriver], registry.get_drivers('label_printer') + ): + machines.extend(driver.get_printers(template, items_to_print)) + self.fields['machine'].choices = [(m.pk, m.name) for m in machines] + + machine = serializers.ChoiceField(choices=[]) + + driver_options = DependentField( + depends_on=['machine'], + field_serializer='get_driver_options', + required=False, + ) + + def get_driver_options(self, fields): + """Returns the selected machines serializer.""" + _, driver = get_machine_and_driver(fields['machine']) + + if driver is None: + return None + + return driver.get_printing_options_serializer( + self.context['request'], context=self.context + ) From 58ad80b4597036600567d9793034764a73a56710 Mon Sep 17 00:00:00 2001 From: wolflu05 <76838159+wolflu05@users.noreply.github.com> Date: Fri, 2 Feb 2024 15:55:44 +0000 Subject: [PATCH 58/86] Remove translated column names because they are now retrieved from the api --- .../src/tables/machine/MachineListTable.tsx | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/src/frontend/src/tables/machine/MachineListTable.tsx b/src/frontend/src/tables/machine/MachineListTable.tsx index ea8fed09629..390e493d91a 100644 --- a/src/frontend/src/tables/machine/MachineListTable.tsx +++ b/src/frontend/src/tables/machine/MachineListTable.tsx @@ -402,7 +402,6 @@ export function MachineListTable({ () => [ { accessor: 'name', - title: t`Machine`, sortable: true, render: function (record) { return ( @@ -420,7 +419,6 @@ export function MachineListTable({ }, { accessor: 'machine_type', - title: t`Machine Type`, sortable: true, render: (record) => { const machineType = machineTypes?.find( @@ -438,7 +436,6 @@ export function MachineListTable({ }, { accessor: 'driver', - title: t`Machine Driver`, sortable: true, render: (record) => { const driver = machineDrivers?.find((d) => d.slug === record.driver); @@ -451,16 +448,13 @@ export function MachineListTable({ } }, BooleanColumn({ - accessor: 'initialized', - title: t`Initialized` + accessor: 'initialized' }), BooleanColumn({ - accessor: 'active', - title: t`Active` + accessor: 'active' }), { accessor: 'status', - title: t`Status`, sortable: false, render: (record) => { const renderer = TableStatusRenderer( @@ -578,12 +572,10 @@ export function MachineListTable({ tableFilters: [ { name: 'active', - label: t`Active`, type: 'boolean' }, { name: 'machine_type', - label: t`Machine Type`, type: 'choice', choiceFunction: () => machineTypes @@ -592,7 +584,6 @@ export function MachineListTable({ }, { name: 'driver', - label: t`Machine Driver`, type: 'choice', choiceFunction: () => machineDrivers From d4ed13e6a660ba8f6d8f668fd895d6b0cb5473fc Mon Sep 17 00:00:00 2001 From: wolflu05 <76838159+wolflu05@users.noreply.github.com> Date: Sat, 3 Feb 2024 17:41:15 +0000 Subject: [PATCH 59/86] Added printer location setting --- InvenTree/InvenTree/serializers.py | 11 ++++++-- .../machine_types/LabelPrintingMachineType.py | 26 +++++++++++++++++-- InvenTree/machine/serializers.py | 10 ++----- .../builtin/labels/inventree_machine.py | 23 +++++++++++++--- .../src/components/items/ActionDropdown.tsx | 8 ++++-- 5 files changed, 60 insertions(+), 18 deletions(-) diff --git a/InvenTree/InvenTree/serializers.py b/InvenTree/InvenTree/serializers.py index 4f13ec9face..e0e6d4d0b30 100644 --- a/InvenTree/InvenTree/serializers.py +++ b/InvenTree/InvenTree/serializers.py @@ -155,8 +155,15 @@ def visit_parent(node): # check if the request data contains the dependent fields, otherwise skip getting the child for f in self.depends_on: - if not data.get(f, None): - return + if data.get(f, None) is None: + if ( + self.parent + and (v := getattr(self.parent.fields[f], 'default', None)) + is not None + ): + data[f] = v + else: + return # partially validate the data for options requests that set raise_exception while calling .get_child(...) if raise_exception: diff --git a/InvenTree/machine/machine_types/LabelPrintingMachineType.py b/InvenTree/machine/machine_types/LabelPrintingMachineType.py index 873b1fe7d66..2491c49a612 100644 --- a/InvenTree/machine/machine_types/LabelPrintingMachineType.py +++ b/InvenTree/machine/machine_types/LabelPrintingMachineType.py @@ -14,6 +14,7 @@ from machine.machine_type import BaseDriver, BaseMachineType, MachineStatus from plugin import registry as plg_registry from plugin.base.label.mixins import LabelItemType, LabelPrintingMixin +from stock.models import StockLocation class BaseLabelPrintingDriver(BaseDriver): @@ -77,7 +78,7 @@ def print_labels( def get_printers( self, label: LabelTemplate, items: QuerySet[LabelItemType], **kwargs - ) -> list[BaseMachineType]: + ) -> list['LabelPrintingMachineType']: """Get all printers that would be available to print this job. By default all printers that are initialized using this driver are returned. @@ -85,8 +86,11 @@ def get_printers( Arguments: label: The LabelTemplate object to use for printing items: The lost of database items to print (e.g. StockItem instances) + + Keyword Arguments: + request: The django request used to make the get printers request """ - return self.get_machines() + return cast(list['LabelPrintingMachineType'], self.get_machines()) def get_printing_options_serializer( self, request: Request, *args, **kwargs @@ -192,6 +196,14 @@ class LabelPrintingMachineType(BaseMachineType): base_driver = BaseLabelPrintingDriver + MACHINE_SETTINGS = { + 'LOCATION': { + 'name': _('Printer Location'), + 'description': _('Scope the printer to a specific location'), + 'model': 'stock.stocklocation', + } + } + class LabelPrinterStatus(MachineStatus): """Label printer status codes.""" @@ -204,3 +216,13 @@ class LabelPrinterStatus(MachineStatus): MACHINE_STATUS = LabelPrinterStatus default_machine_status = LabelPrinterStatus.DISCONNECTED + + @property + def location(self): + """Access the machines location instance using this property.""" + location_pk = self.get_setting('LOCATION', 'M') + + if not location_pk: + return None + + return StockLocation.objects.get(pk=location_pk) diff --git a/InvenTree/machine/serializers.py b/InvenTree/machine/serializers.py index 02df3f6caa9..16e024aec07 100644 --- a/InvenTree/machine/serializers.py +++ b/InvenTree/machine/serializers.py @@ -95,16 +95,10 @@ class MachineSettingSerializer(GenericReferencedSettingSerializer): EXTRA_FIELDS = ['config_type'] def __init__(self, *args, **kwargs): - """Custom init method to remove unwanted fields.""" + """Custom init method to make the config_type field read only.""" super().__init__(*args, **kwargs) - # remove unwanted fields - unwanted_fields = ['pk', 'model_name', 'api_url', 'typ'] - for f in unwanted_fields: - if f in self.Meta.fields: - self.Meta.fields.remove(f) - - self.Meta.read_only_fields = ['config_type'] + self.Meta.read_only_fields = ['config_type'] # type: ignore class BaseMachineClassSerializer(serializers.Serializer): diff --git a/InvenTree/plugin/builtin/labels/inventree_machine.py b/InvenTree/plugin/builtin/labels/inventree_machine.py index 02ae4779b84..9aa55977f15 100644 --- a/InvenTree/plugin/builtin/labels/inventree_machine.py +++ b/InvenTree/plugin/builtin/labels/inventree_machine.py @@ -10,7 +10,6 @@ from InvenTree.serializers import DependentField from InvenTree.tasks import offload_task from label.models import LabelTemplate -from machine.machine_type import BaseMachineType from machine.machine_types import BaseLabelPrintingDriver, LabelPrintingMachineType from plugin import InvenTreePlugin from plugin.machine import registry @@ -85,12 +84,28 @@ def __init__(self, *args, **kwargs): template = view.get_object() items_to_print = view.get_items() - machines: list[BaseMachineType] = [] + machines: list[LabelPrintingMachineType] = [] for driver in cast( list[BaseLabelPrintingDriver], registry.get_drivers('label_printer') ): - machines.extend(driver.get_printers(template, items_to_print)) - self.fields['machine'].choices = [(m.pk, m.name) for m in machines] + machines.extend( + driver.get_printers( + template, items_to_print, request=kwargs['context']['request'] + ) + ) + choices = [(m.pk, self.get_printer_name(m)) for m in machines] + self.fields['machine'].choices = choices + if len(choices) > 0: + self.fields['machine'].default = choices[0][0] + + def get_printer_name(self, machine: LabelPrintingMachineType): + """Construct the printers name.""" + name = machine.name + + if machine.location: + name += f' @ {machine.location.name}' + + return name machine = serializers.ChoiceField(choices=[]) diff --git a/src/frontend/src/components/items/ActionDropdown.tsx b/src/frontend/src/components/items/ActionDropdown.tsx index f26408c6e1e..833273ca647 100644 --- a/src/frontend/src/components/items/ActionDropdown.tsx +++ b/src/frontend/src/components/items/ActionDropdown.tsx @@ -62,8 +62,12 @@ export function ActionDropdown({ {actions.map((action) => action.disabled ? null : ( - - + + { From a9808c64911b287ee56368b10b74d9d8b0dde576 Mon Sep 17 00:00:00 2001 From: wolflu05 <76838159+wolflu05@users.noreply.github.com> Date: Sat, 3 Feb 2024 19:11:31 +0000 Subject: [PATCH 60/86] Save last 10 used printer machine per user and sort them in the printing dialog --- InvenTree/common/models.py | 5 ++ InvenTree/machine/machine_type.py | 4 ++ .../builtin/labels/inventree_machine.py | 56 ++++++++++++++++++- 3 files changed, 63 insertions(+), 2 deletions(-) diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py index 84d02cba269..8b0fc88035f 100644 --- a/InvenTree/common/models.py +++ b/InvenTree/common/models.py @@ -2339,6 +2339,11 @@ class Meta: 'default': True, 'validator': bool, }, + 'LAST_USED_PRINTING_MACHINES': { + 'name': _('Last used printing machines'), + 'description': _('Save the last used printing machines for a user'), + 'default': '', + }, } typ = 'user' diff --git a/InvenTree/machine/machine_type.py b/InvenTree/machine/machine_type.py index a217c952efa..501565001d7 100644 --- a/InvenTree/machine/machine_type.py +++ b/InvenTree/machine/machine_type.py @@ -215,6 +215,10 @@ def __str__(self): """String representation of a machine.""" return f'{self.name}' + def __repr__(self): + """Python representation of a machine.""" + return f'<{self.__class__.__name__}: {self.name}>' + # --- properties @property def machine_config(self): diff --git a/InvenTree/plugin/builtin/labels/inventree_machine.py b/InvenTree/plugin/builtin/labels/inventree_machine.py index 9aa55977f15..eae8a1c9906 100644 --- a/InvenTree/plugin/builtin/labels/inventree_machine.py +++ b/InvenTree/plugin/builtin/labels/inventree_machine.py @@ -7,6 +7,7 @@ from rest_framework import serializers +from common.models import InvenTreeUserSetting from InvenTree.serializers import DependentField from InvenTree.tasks import offload_task from label.models import LabelTemplate @@ -35,6 +36,20 @@ def get_machine_and_driver(machine_pk: str): return machine, cast(BaseLabelPrintingDriver, driver) +def get_last_used_printers(user): + """Get the last used printers for a specific user.""" + return [ + printer + for printer in cast( + str, + InvenTreeUserSetting.get_setting( + 'LAST_USED_PRINTING_MACHINES', '', user=user + ), + ).split(',') + if printer + ] + + class InvenTreeLabelPlugin(LabelPrintingMixin, InvenTreePlugin): """Builtin plugin for machine label printing. @@ -61,6 +76,20 @@ def print_labels(self, label: LabelTemplate, items, request, **kwargs): 'printing_options': kwargs['printing_options'].get('driver_options', {}), } + # save the current used printer as last used printer + # only the last ten used printers are saved so that this list doesn't grow infinitely + last_used_printers = get_last_used_printers(request.user) + machine_pk = str(machine.pk) + if machine_pk in last_used_printers: + last_used_printers.remove(machine_pk) + last_used_printers.insert(0, machine_pk) + InvenTreeUserSetting.set_setting( + 'LAST_USED_PRINTING_MACHINES', + ','.join(last_used_printers[:10]), + user=request.user, + ) + + # execute the print job if driver.USE_BACKGROUND_WORKER is False: return driver.print_labels(machine, label, items, request, **print_kwargs) @@ -84,6 +113,7 @@ def __init__(self, *args, **kwargs): template = view.get_object() items_to_print = view.get_items() + # get all available printers for each driver machines: list[LabelPrintingMachineType] = [] for driver in cast( list[BaseLabelPrintingDriver], registry.get_drivers('label_printer') @@ -93,11 +123,33 @@ def __init__(self, *args, **kwargs): template, items_to_print, request=kwargs['context']['request'] ) ) - choices = [(m.pk, self.get_printer_name(m)) for m in machines] - self.fields['machine'].choices = choices + + # sort the last used printers for the user to the top + user = kwargs['context']['request'].user + last_used_printers = get_last_used_printers(user)[::-1] + machines = sorted( + machines, + key=lambda m: last_used_printers.index(str(m.pk)) + if str(m.pk) in last_used_printers + else -1, + reverse=True, + ) + + choices = [(str(m.pk), self.get_printer_name(m)) for m in machines] + + # if there are choices available, use the first as default if len(choices) > 0: self.fields['machine'].default = choices[0][0] + # add 'last used' flag to the first choice + if choices[0][0] in last_used_printers: + choices[0] = ( + choices[0][0], + choices[0][1] + ' (' + _('last used') + ')', + ) + + self.fields['machine'].choices = choices + def get_printer_name(self, machine: LabelPrintingMachineType): """Construct the printers name.""" name = machine.name From d4c188481cc6f57f1abecf84f8518653c641c8c1 Mon Sep 17 00:00:00 2001 From: wolflu05 <76838159+wolflu05@users.noreply.github.com> Date: Sat, 3 Feb 2024 19:46:52 +0000 Subject: [PATCH 61/86] Added BasePrintingOptionsSerializer for common options --- .../machine_types/LabelPrintingMachineType.py | 19 +++++++++++++++++-- .../builtin/labels/inventree_machine.py | 1 + 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/InvenTree/machine/machine_types/LabelPrintingMachineType.py b/InvenTree/machine/machine_types/LabelPrintingMachineType.py index 2491c49a612..df22ca78306 100644 --- a/InvenTree/machine/machine_types/LabelPrintingMachineType.py +++ b/InvenTree/machine/machine_types/LabelPrintingMachineType.py @@ -43,6 +43,8 @@ def print_label( Keyword Arguments: printing_options: The printing options set for this print job defined in the PrintingOptionsSerializer + by default the following options are available: + - copies: number of copies to print for the label Note that the supplied args/kwargs may be different if the driver overrides the print_labels() method. """ @@ -66,6 +68,8 @@ def print_labels( Keyword Arguments: printing_options: The printing options set for this print job defined in the PrintingOptionsSerializer + by default the following options are available: + - copies: number of copies to print for each label Returns: If USE_BACKGROUND_WORKER=False, a JsonResponse object which indicates outcome to the user, otherwise None @@ -94,7 +98,7 @@ def get_printers( def get_printing_options_serializer( self, request: Request, *args, **kwargs - ) -> Union[serializers.Serializer, None]: + ) -> 'BaseLabelPrintingDriver.BasePrintingOptionsSerializer': """Return a serializer class instance with dynamic printing options. Arguments: @@ -108,7 +112,9 @@ def get_printing_options_serializer( serializer = getattr(self, 'PrintingOptionsSerializer', None) if not serializer: - return None + return BaseLabelPrintingDriver.BasePrintingOptionsSerializer( + *args, **kwargs + ) return serializer(*args, **kwargs) @@ -186,6 +192,15 @@ def render_to_png( required_overrides = [[print_label, print_labels]] + class BasePrintingOptionsSerializer(serializers.Serializer): + """Printing options base serializer that implements common options.""" + + copies = serializers.IntegerField( + default=1, + label=_('Copies'), + help_text=_('Number of copies to print for each label'), + ) + class LabelPrintingMachineType(BaseMachineType): """Label printer machine type, is a direct integration to print labels for various items.""" diff --git a/InvenTree/plugin/builtin/labels/inventree_machine.py b/InvenTree/plugin/builtin/labels/inventree_machine.py index eae8a1c9906..826564dad9f 100644 --- a/InvenTree/plugin/builtin/labels/inventree_machine.py +++ b/InvenTree/plugin/builtin/labels/inventree_machine.py @@ -162,6 +162,7 @@ def get_printer_name(self, machine: LabelPrintingMachineType): machine = serializers.ChoiceField(choices=[]) driver_options = DependentField( + label=_('Options'), depends_on=['machine'], field_serializer='get_driver_options', required=False, From 8e4ad5c2c70c4e2db09ec22366c95b9e8c1e30f3 Mon Sep 17 00:00:00 2001 From: wolflu05 <76838159+wolflu05@users.noreply.github.com> Date: Sat, 3 Feb 2024 20:38:36 +0000 Subject: [PATCH 62/86] Fix not printing_options are not properly casted to its internal value --- InvenTree/label/api.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/InvenTree/label/api.py b/InvenTree/label/api.py index 694e3b9d906..f9b42e70693 100644 --- a/InvenTree/label/api.py +++ b/InvenTree/label/api.py @@ -233,7 +233,10 @@ def print(self, request, items_to_print): # The plugin is responsible for handling the request and returning a response. result = plugin.print_labels( - label, items_to_print, request, printing_options=request.data + label, + items_to_print, + request, + printing_options=(serializer.data if serializer else {}), ) if isinstance(result, JsonResponse): From da1aabb3d9b572a381a1244f2577ec200ae53b95 Mon Sep 17 00:00:00 2001 From: wolflu05 <76838159+wolflu05@users.noreply.github.com> Date: Sat, 3 Feb 2024 21:51:07 +0000 Subject: [PATCH 63/86] Fix type --- InvenTree/machine/machine_types/LabelPrintingMachineType.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/InvenTree/machine/machine_types/LabelPrintingMachineType.py b/InvenTree/machine/machine_types/LabelPrintingMachineType.py index df22ca78306..c7630ea9a7c 100644 --- a/InvenTree/machine/machine_types/LabelPrintingMachineType.py +++ b/InvenTree/machine/machine_types/LabelPrintingMachineType.py @@ -114,7 +114,7 @@ def get_printing_options_serializer( if not serializer: return BaseLabelPrintingDriver.BasePrintingOptionsSerializer( *args, **kwargs - ) + ) # type: ignore return serializer(*args, **kwargs) From e4b55696fe031eef51269e1365055ed1e9950760 Mon Sep 17 00:00:00 2001 From: wolflu05 <76838159+wolflu05@users.noreply.github.com> Date: Sun, 4 Feb 2024 15:36:30 +0000 Subject: [PATCH 64/86] Improved machine docs --- InvenTree/machine/machine_type.py | 75 ++++--- .../machine_types/LabelPrintingMachineType.py | 95 ++++---- InvenTree/plugin/machine/__init__.py | 11 +- InvenTree/plugin/machine/machine_types.py | 3 + docs/docs/extend/machines/label_printer.md | 35 +++ docs/docs/extend/machines/overview.md | 202 +++++++++++++++++ docs/docs/extend/plugins/machines.md | 204 ------------------ docs/mkdocs.yml | 11 +- docs/requirements.txt | 1 + 9 files changed, 361 insertions(+), 276 deletions(-) create mode 100644 InvenTree/plugin/machine/machine_types.py create mode 100644 docs/docs/extend/machines/label_printer.md create mode 100644 docs/docs/extend/machines/overview.md delete mode 100644 docs/docs/extend/plugins/machines.md diff --git a/InvenTree/machine/machine_type.py b/InvenTree/machine/machine_type.py index 501565001d7..00fa66ba14b 100644 --- a/InvenTree/machine/machine_type.py +++ b/InvenTree/machine/machine_type.py @@ -35,25 +35,27 @@ class MachineStatus(StatusCode): Additionally there are helpers to access all additional attributes `text`, `label`, `color`. Status code ranges: + ``` 1XX - Everything fine 2XX - Warnings (e.g. ink is about to become empty) 3XX - Something wrong with the machine (e.g. no labels are remaining on the spool) 4XX - Something wrong with the driver (e.g. cannot connect to the machine) 5XX - Unknown issues + ``` """ pass class BaseDriver(ClassValidationMixin, ClassProviderMixin): - """Base class for machine drivers. + """Base class for all machine drivers. Attributes: - SLUG: Slug string for identifying a machine - NAME: User friendly name for displaying - DESCRIPTION: Description of what this driver does + SLUG: Slug string for identifying the driver in format /[a-z-]+/ (required) + NAME: User friendly name for displaying (required) + DESCRIPTION: Description of what this driver does (required) - MACHINE_SETTINGS: Driver specific settings dict (optional) + MACHINE_SETTINGS: Driver specific settings dict """ SLUG: str @@ -109,7 +111,7 @@ def restart_machine(self, machine: 'BaseMachineType'): """This method gets called on manual machine restart e.g. by using the restart machine action in the Admin Center. Note: - machine.restart_required gets set to False again + `machine.restart_required` gets set to False again before this function is called Arguments: machine: Machine instance @@ -119,12 +121,12 @@ def restart_machine(self, machine: 'BaseMachineType'): def get_machines(self, **kwargs): """Return all machines using this driver (By default only initialized machines). - Kwargs: - name: Machine name - machine_type: Machine type definition (class) - initialized: (bool, default: True) - active: (bool) - base_driver: base driver (class) + Keyword Arguments: + name (str): Machine name + machine_type (BaseMachineType): Machine type definition (class) + initialized (bool): default: True + active (bool): machine needs to be active + base_driver (BaseDriver): base driver (class) """ from machine import registry @@ -133,7 +135,11 @@ def get_machines(self, **kwargs): return registry.get_machines(driver=self, **kwargs) def handle_error(self, error: Union[Exception, str]): - """Handle driver error.""" + """Handle driver error. + + Arguments: + error: Exception or string + """ self.errors.append(error) @@ -141,9 +147,9 @@ class BaseMachineType(ClassValidationMixin, ClassProviderMixin): """Base class for machine types. Attributes: - SLUG: Slug string for identifying a machine type - NAME: User friendly name for displaying - DESCRIPTION: Description of what this machine type can do (default: "") + SLUG: Slug string for identifying the machine type in format /[a-z-]+/ (required) + NAME: User friendly name for displaying (required) + DESCRIPTION: Description of what this machine type can do (required) base_driver: Reference to the base driver for this machine type @@ -222,7 +228,7 @@ def __repr__(self): # --- properties @property def machine_config(self): - """Machine_config property.""" + """Machine_config property which is a reference to the database entry.""" # always fetch the machine_config if needed to ensure we get the newest reference from .models import MachineConfig @@ -230,12 +236,12 @@ def machine_config(self): @property def name(self): - """Name property.""" + """The machines name.""" return self.machine_config.name @property def active(self): - """Active property.""" + """The machines active status.""" return self.machine_config.active # --- hook functions @@ -263,7 +269,11 @@ def initialize(self): self.handle_error(e) def update(self, old_state: dict[str, Any]): - """Machine update function, gets called if the machine itself changes or their settings.""" + """Machine update function, gets called if the machine itself changes or their settings. + + Arguments: + old_state: Dict holding the old machine state before update + """ if self.driver is None: return @@ -285,15 +295,21 @@ def restart(self): # --- helper functions def handle_error(self, error: Union[Exception, str]): - """Helper function for capturing errors with the machine.""" + """Helper function for capturing errors with the machine. + + Arguments: + error: Exception or string + """ self.errors.append(error) - def get_setting(self, key: str, config_type_str: Literal['M', 'D'], cache=False): + def get_setting( + self, key: str, config_type_str: Literal['M', 'D'], cache: bool = False + ): """Return the 'value' of the setting associated with this machine. Arguments: key: The 'name' of the setting value to be retrieved - config_type: Either "M" (machine scoped settings) or "D" (driver scoped settings) + config_type_str: Either "M" (machine scoped settings) or "D" (driver scoped settings) cache: Whether to use RAM cached value (default = False) """ from machine.models import MachineSetting @@ -306,12 +322,12 @@ def get_setting(self, key: str, config_type_str: Literal['M', 'D'], cache=False) cache=cache, ) - def set_setting(self, key: str, config_type_str: Literal['M', 'D'], value): + def set_setting(self, key: str, config_type_str: Literal['M', 'D'], value: Any): """Set plugin setting value by key. Arguments: key: The 'name' of the setting to set - config_type: Either "M" (machine scoped settings) or "D" (driver scoped settings) + config_type_str: Either "M" (machine scoped settings) or "D" (driver scoped settings) value: The 'value' of the setting """ from machine.models import MachineSetting @@ -351,9 +367,16 @@ def set_status(self, status: MachineStatus): """Set the machine status code. There are predefined ones for each MachineType. Import the MachineType to access it's `MACHINE_STATUS` enum. + + Arguments: + status: The new MachineStatus code to set """ self.status = status def set_status_text(self, status_text: str): - """Set the machine status text. It can be any arbitrary text.""" + """Set the machine status text. It can be any arbitrary text. + + Arguments: + status_text: The new status text to set + """ self.status_text = status_text diff --git a/InvenTree/machine/machine_types/LabelPrintingMachineType.py b/InvenTree/machine/machine_types/LabelPrintingMachineType.py index c7630ea9a7c..936430f4d9d 100644 --- a/InvenTree/machine/machine_types/LabelPrintingMachineType.py +++ b/InvenTree/machine/machine_types/LabelPrintingMachineType.py @@ -18,11 +18,14 @@ class BaseLabelPrintingDriver(BaseDriver): - """Base label printing driver.""" + """Base label printing driver. + + Attributes: + USE_BACKGROUND_WORKER (bool): If True, the `print_label()` and `print_labels()` methods will be run in a background worker (default: True) + """ machine_type = 'label_printer' - # Run printing functions by default in a background worker. USE_BACKGROUND_WORKER = True def print_label( @@ -42,7 +45,7 @@ def print_label( request: The HTTP request object which triggered this print job Keyword Arguments: - printing_options: The printing options set for this print job defined in the PrintingOptionsSerializer + printing_options (dict): The printing options set for this print job defined in the PrintingOptionsSerializer by default the following options are available: - copies: number of copies to print for the label @@ -67,12 +70,12 @@ def print_labels( request: The HTTP request object which triggered this print job Keyword Arguments: - printing_options: The printing options set for this print job defined in the PrintingOptionsSerializer + printing_options (dict): The printing options set for this print job defined in the PrintingOptionsSerializer by default the following options are available: - copies: number of copies to print for each label Returns: - If USE_BACKGROUND_WORKER=False, a JsonResponse object which indicates outcome to the user, otherwise None + If `USE_BACKGROUND_WORKER=False`, a JsonResponse object which indicates outcome to the user, otherwise None The default implementation simply calls print_label() for each label, producing multiple single label output "jobs" but this can be overridden by the particular driver. @@ -92,43 +95,37 @@ def get_printers( items: The lost of database items to print (e.g. StockItem instances) Keyword Arguments: - request: The django request used to make the get printers request + request (Request): The django request used to make the get printers request """ return cast(list['LabelPrintingMachineType'], self.get_machines()) def get_printing_options_serializer( self, request: Request, *args, **kwargs - ) -> 'BaseLabelPrintingDriver.BasePrintingOptionsSerializer': + ) -> 'BaseLabelPrintingDriver.PrintingOptionsSerializer': """Return a serializer class instance with dynamic printing options. Arguments: request: The request made to print a label or interfering the available serializer fields via an OPTIONS request - *args, **kwargs: need to be passed to the serializer instance + + Note: + `*args`, `**kwargs` needs to be passed to the serializer instance Returns: - A class instance of a DRF serializer class, by default this an instance of - self.PrintingOptionsSerializer using the *args, **kwargs if existing for this driver + A class instance of a DRF serializer class, by default this an instance of self.PrintingOptionsSerializer using the *args, **kwargs if existing for this driver """ - serializer = getattr(self, 'PrintingOptionsSerializer', None) - - if not serializer: - return BaseLabelPrintingDriver.BasePrintingOptionsSerializer( - *args, **kwargs - ) # type: ignore - - return serializer(*args, **kwargs) + return self.PrintingOptionsSerializer(*args, **kwargs) # --- helper functions @property def machine_plugin(self) -> LabelPrintingMixin: - """Returns the builtin machine label printing plugin.""" + """Returns the builtin machine label printing plugin that manages printing through machines.""" plg = plg_registry.get_plugin('inventreelabelmachine') return cast(LabelPrintingMixin, plg) def render_to_pdf( self, label: LabelTemplate, item: LabelItemType, request: Request, **kwargs ) -> HttpResponse: - """Render this label to PDF format. + """Helper method to render a label to PDF format for a specific item. Arguments: label: The LabelTemplate object to render @@ -143,7 +140,7 @@ def render_to_pdf( def render_to_pdf_data( self, label: LabelTemplate, item: LabelItemType, request: Request, **kwargs ) -> bytes: - """Render this label to PDF and return it as 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 @@ -159,7 +156,7 @@ def render_to_pdf_data( def render_to_html( self, label: LabelTemplate, item: LabelItemType, request: Request, **kwargs ) -> str: - """Render this label to HTML format. + """Helper method to render a label to HTML format for a specific item. Arguments: label: The LabelTemplate object to render @@ -174,7 +171,7 @@ def render_to_html( def render_to_png( self, label: LabelTemplate, item: LabelItemType, request: Request, **kwargs ) -> Image: - """Render this label to PNG format. + """Helper method to render a label to PNG format for a specific item. Arguments: label: The LabelTemplate object to render @@ -182,8 +179,8 @@ def render_to_png( request: The HTTP request object which triggered this print job Keyword Arguments: - pdf_data: The pdf document as bytes (optional) - dpi: The dpi used to render the image (optional) + pdf_data (bytes): The pdf document as bytes (optional) + dpi (int): The dpi used to render the image (optional) """ label.object_to_print = item png = self.machine_plugin.render_to_png(label, request, **kwargs) @@ -192,8 +189,25 @@ def render_to_png( required_overrides = [[print_label, print_labels]] - class BasePrintingOptionsSerializer(serializers.Serializer): - """Printing options base serializer that implements common options.""" + class PrintingOptionsSerializer(serializers.Serializer): + """Printing options serializer that implements common options. + + This can be overridden by the driver to implement custom options, but the driver should always extend this class. + + Example: + This example shows how to extend the default serializer and add a new option: + ```py + class MyDriver(BaseLabelPrintingDriver): + # ... + + class PrintingOptionsSerializer(BaseLabelPrintingDriver.PrintingOptionsSerializer): + auto_cut = serializers.BooleanField( + default=True, + label=_('Auto cut'), + help_text=_('Automatically cut the label after printing'), + ) + ``` + """ copies = serializers.IntegerField( default=1, @@ -202,6 +216,24 @@ class BasePrintingOptionsSerializer(serializers.Serializer): ) +class LabelPrinterStatus(MachineStatus): + """Label printer status codes. + + Attributes: + CONNECTED: The printer is connected and ready to print + STANDBY: The printers connection is in standby mode, but will be ready to print at any time + PRINTING: The printer is currently printing a label + NO_MEDIA: The printer is out of media (e.g. the label spool is empty) + DISCONNECTED: The driver cannot establish a connection to the printer + """ + + CONNECTED = 100, _('Connected'), 'success' + UNKNOWN = 101, _('Standby'), 'success' + PRINTING = 110, _('Printing'), 'primary' + NO_MEDIA = 301, _('No media'), 'warning' + DISCONNECTED = 400, _('Disconnected'), 'danger' + + class LabelPrintingMachineType(BaseMachineType): """Label printer machine type, is a direct integration to print labels for various items.""" @@ -219,15 +251,6 @@ class LabelPrintingMachineType(BaseMachineType): } } - class LabelPrinterStatus(MachineStatus): - """Label printer status codes.""" - - CONNECTED = 100, _('Connected'), 'success' - STANDBY = 101, _('Standby'), 'success' - PRINTING = 110, _('Printing'), 'primary' - LABEL_SPOOL_EMPTY = 301, _('Label spool empty'), 'warning' - DISCONNECTED = 400, _('Disconnected'), 'danger' - MACHINE_STATUS = LabelPrinterStatus default_machine_status = LabelPrinterStatus.DISCONNECTED diff --git a/InvenTree/plugin/machine/__init__.py b/InvenTree/plugin/machine/__init__.py index df104dd6929..617df0762b3 100644 --- a/InvenTree/plugin/machine/__init__.py +++ b/InvenTree/plugin/machine/__init__.py @@ -1,10 +1,3 @@ -from machine import BaseDriver, BaseMachineType, MachineStatus, machine_types, registry -from machine.machine_types import * # noqa: F403, F401 +from machine import BaseDriver, BaseMachineType, MachineStatus, registry -__all__ = [ - 'registry', - 'BaseDriver', - 'BaseMachineType', - 'MachineStatus', - *machine_types.__all__, -] +__all__ = ['registry', 'BaseDriver', 'BaseMachineType', 'MachineStatus'] diff --git a/InvenTree/plugin/machine/machine_types.py b/InvenTree/plugin/machine/machine_types.py new file mode 100644 index 00000000000..f83dea24703 --- /dev/null +++ b/InvenTree/plugin/machine/machine_types.py @@ -0,0 +1,3 @@ +"""just re-export the machine types from the plugin InvenTree app.""" + +from machine.machine_types import * # noqa: F403, F401 diff --git a/docs/docs/extend/machines/label_printer.md b/docs/docs/extend/machines/label_printer.md new file mode 100644 index 00000000000..ea49dadb9d7 --- /dev/null +++ b/docs/docs/extend/machines/label_printer.md @@ -0,0 +1,35 @@ +## Label printer + +Label printer machines can directly print labels for various items in InvenTree. They replace standard [`LabelPrintingMixin`](../plugins/label.md) plugins that are used to connect to physical printers. Using machines rather than a standard `LabelPrintingMixin` plugin has the advantage that machines can be created multiple times using different settings but the same driver. That way multiple label printers of the same brand can be connected. + +### Writing your own printing driver + +Take a look at the most basic required code for a driver in this [example](./overview.md#example-driver). Next either implement the [`print_label`](#machine.machine_types.BaseLabelPrintingDriver.print_label) or [`print_labels`](#machine.machine_types.BaseLabelPrintingDriver.print_labels) function. + +### Label printer status + +There are a couple of predefined status codes for label printers. By default the Disconnected status code is set. + +::: machine.machine_types.LabelPrintingMachineType.LabelPrinterStatus + options: + heading_level: 4 + show_bases: false + show_docstring_description: false + +### LabelPrintingDriver API + +::: machine.machine_types.BaseLabelPrintingDriver + options: + heading_level: 4 + show_bases: false + members: + - print_label + - print_labels + - get_printers + - PrintingOptionsSerializer + - get_printing_options_serializer + - machine_plugin + - render_to_pdf + - render_to_pdf_data + - render_to_html + - render_to_png diff --git a/docs/docs/extend/machines/overview.md b/docs/docs/extend/machines/overview.md new file mode 100644 index 00000000000..4e1c29fabff --- /dev/null +++ b/docs/docs/extend/machines/overview.md @@ -0,0 +1,202 @@ +--- +title: Machines +--- + +## 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. + +### Registry + +The machine registry is the main component which gets initialized on server start and manages all configured machines. + +#### Initialization process + +The machine registry initialization process can be divided into three stages: + +- **Stage 1: Discover machine types:** by looking for classes that inherit the BaseMachineType class +- **Stage 2: Discover drivers:** by looking for classes that inherit the BaseDriver class (and are not referenced as base driver for any discovered machine type) +- **Stage 3: Machine loading:** + 1. For each MachineConfig in database instantiate the MachineType class (drivers get instantiated here as needed and passed to the machine class. But only one instance of the driver class is maintained along the registry) + 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 + +### Machine types + +Each machine type can provide a different type of connection functionality between inventree and a physical machine. These machine types are already built into InvenTree. + +#### Built-in types + +| Name | Description | +| --- | --- | +| [Label printer](./label_printer.md) | Directly print labels for various items. | + +#### Example machine type + +If you want to create your own machine type, please also take a look at the already existing machine types in `machines/machine_types/*.py`. The following example creates a machine type called `abc`. + +```py +from django.utils.translation import ugettext_lazy as _ +from plugin.machine import BaseDriver, BaseMachineType, MachineStatus + +class BaseABCDriver(BaseDriver): + """Base xyz driver.""" + + machine_type = 'abc' + + def my_custom_required_method(self): + """This function must be overridden.""" + raise NotImplementedError('The `my_custom_required_method` function must be overridden!') + + def my_custom_method(self): + """This function can be overridden.""" + raise NotImplementedError('The `my_custom_method` function can be overridden!') + + required_overrides = [my_custom_required_method] + +class ABCMachineType(BaseMachineType): + SLUG = 'abc' + NAME = _('ABC') + DESCRIPTION = _('This is an awesome machine type for ABC.') + + base_driver = BaseABCDriver + + class ABCStatus(MachineStatus): + CONNECTED = 100, _('Connected'), 'success' + STANDBY = 101, _('Standby'), 'success' + PRINTING = 110, _('Printing'), 'primary' + + MACHINE_STATUS = ABCStatus + + default_machine_status = ABCStatus.DISCONNECTED +``` + +#### Machine Type API + +The machine type class gets instantiated for each machine on server startup and the reference is stored in the machine registry. (Therefore `machine.NAME` is the machine type name and `machine.name` links to the machine instances user defined name) + +::: machine.BaseMachineType + options: + heading_level: 5 + show_bases: false + members: + - machine_config + - name + - active + - initialize + - update + - restart + - handle_error + - get_setting + - set_setting + - check_setting + - set_status + - set_status_text + +### Drivers + +Drivers provide the connection layer between physical machines and inventree. There can be multiple drivers defined for the same machine type. Drivers are provided by plugins that are enabled and extend the corresponding base driver for the particular machine type. Each machine type already provides a base driver that needs to be inherited. + +#### Example driver + +A basic driver only needs to specify the basic attributes like `SLUG`, `NAME`, `DESCRIPTION`. The others are given by the used base driver, so take a look at [Machine types](#machine-types). The following example will create an driver called `abc` for the `xyz` machine type. The class will be discovered if it is provided by an **installed & activated** plugin just like this: + +```py +from plugin import InvenTreePlugin +from plugin.machine.machine_types import BaseXYZDriver + +class MyABCXYZDriverPlugin(InvenTreePlugin): + NAME = "ABCXYZDriver" + SLUG = "abc-driver" + TITLE = "ABC XYZ Driver" + # ... + +class MyXYZDriver(BaseXYZDriver): + SLUG = 'my-abc-driver' + NAME = 'My ABC driver' + DESCRIPTION = 'This is an awesome driver for ABC' +``` + +#### Driver API + +::: machine.BaseDriver + options: + heading_level: 5 + show_bases: false + members: + - init_driver + - init_machine + - update_machine + - restart_machine + - get_machines + - handle_error + +### Settings + +Each machine can have different settings configured. There are machine settings that are specific to that machine type and driver settings that are specific to the driver, but both can be specified individually for each machine. Define them by adding a `MACHINE_SETTINGS` dictionary attribute to either the driver or the machine type. The format follows the same pattern as the `SETTINGS` for normal plugins documented on the [`SettingsMixin`](../plugins/settings.md) + +```py +class MyXYZDriver(BaseXYZDriver): + MACHINE_SETTINGS = { + 'SERVER': { + 'name': _('Server'), + 'description': _('IP/Hostname to connect to the cups server'), + 'default': 'localhost', + 'required': True, + } + } +``` + +Settings can even marked as `'required': True` which prevents the machine from starting if the setting is not defined. + +### Machine status + +Machine status can be used to report the machine status to the users. They can be set by the driver for each machine, but get lost on a server restart. + +#### Codes + +Each machine type has a set of status codes defined that can be set for each machine by the driver. There also needs to be a default status code defined. + +```py +from plugin.machine import MachineStatus, BaseMachineType + +class XYZStatus(MachineStatus): + CONNECTED = 100, _('Connected'), 'success' + STANDBY = 101, _('Standby'), 'success' + DISCONNECTED = 400, _('Disconnected'), 'danger' + +class XYZMachineType(BaseMachineType): + # ... + + MACHINE_STATUS = XYZStatus + default_machine_status = XYZStatus.DISCONNECTED +``` + +And to set a status code for a machine by the driver. + +```py +class MyXYZDriver(BaseXYZDriver): + # ... + def init_machine(self, machine): + # ... do some init stuff here + machine.set_status(XYZMachineType.MACHINE_STATUS.CONNECTED) +``` + +**`MachineStatus` API** + +::: machine.machine_type.MachineStatus + options: + heading_level: 5 + show_bases: false + +#### Free text + +There can also be a free text status code defined. + +```py +class MyXYZDriver(BaseXYZDriver): + # ... + def init_machine(self, machine): + # ... do some init stuff here + machine.set_status_text("Paper missing") +``` diff --git a/docs/docs/extend/plugins/machines.md b/docs/docs/extend/plugins/machines.md deleted file mode 100644 index 32db0666f38..00000000000 --- a/docs/docs/extend/plugins/machines.md +++ /dev/null @@ -1,204 +0,0 @@ ---- -title: Machines ---- - -## 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. - -### Registry - -The machine registry is the main component which gets initialized on server start and manages all configured machines. - -#### Initialization process - -The machine registry initialization process can be divided into three stages as described in this diagram: - -```mermaid -flowchart LR - A["`**Server start**`"] --> B - B["`**Stage 1: Discover machine types** - by looking for classes that inherit the BaseMachineType class`"] --> C - C["`**Stage 2: Discover drivers** - by looking for classes that inherit the BaseDriver class (and are not referenced as base driver for any discovered machine type)`"] --> MA - subgraph MA["Stage 3: Machine loading"] - direction TB - D["`For each MachineConfig in database instantiate the MachineType class (drivers get instantiated here as needed and passed to the machine class. But only one instance of the driver class is maintained along the registry)`"] --> E - E["`The driver.init_driver function is called for each used driver`"] --> F - F["`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`"] - end - MA --> X["`**Done**`"] -``` - -### 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. -The machine type class gets instantiated for each machine on server startup and the reference is stored in the machine registry. - -#### Built in types - -| Name | Description | -| ------------------------------- | ---------------------------------------- | -| [Label printer](#label-printer) | Directly print labels for various items. | - -##### Label printer - -Label printer machines can directly print labels for various items in InvenTree. They replace standard [`LabelPrintingMixin`](../plugins/label.md) plugins that are used to connect to physical printers. Using machines rather than a standard `LabelPrintingMixin` plugin has the advantage that machines can be created multiple times using different settings but the same driver. That way multiple label printers of the same brand can be connected. - -TODO - -#### Available attributes - -| Name | Description | -| ------------------------ | --------------------------------------------------------------------------------------------------------------------- | -| `SLUG` | A slug for the machine type needs to be set (short identifier string that satisfies the following format `/[a-z-]+/`) | -| `NAME` | A name for the machine type needs to be set | -| `DESCRIPTION` | A description for the machine type needs to be set | -| `base_driver` | Reference to the base driver class | -| `MACHINE_SETTINGS` | Machine settings dict, see [settings](#settings) | -| `MACHINE_STATUS` | Machine status enum, see [status](#machine-status) | -| `default_machine_status` | default machine status, see [status](#machine-status) | - -#### Available methods - -| Name | Description | -| ------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------- | -| `initialize(self)` | gets called on initialization | -| `update(self, old_machine_state: Dict[str, Any])` | gets called if any changes on a machine or their settings occur. The `machine.restart_required` flag can be set here. | -| `restart(self, machine: 'BaseMachineType')` | gets called if the admin manually presses the restart button on the admin interface | -| `handle_error(self, error: Union[Exception, str])` | helper function to capture errors and show them in the admin ui | -| `get_setting(self, key: str, config_type_str: Literal['M', 'D'], cache=False)` | get a setting for a machine | -| `set_setting(self, key: str, config_type_str: Literal['M', 'D'], value)` | set a setting for a machine | -| `check_settings(self)` | check that all required settings are set | -| `set_status(self, status: MachineStatus)` | set a machine status code | -| `set_status_text(self, status_text: str)` | set a machine status text | - -#### Example machine type - -If you want to create your own machine type, please also take a look at the already existing machine types. - -```py -from django.utils.translation import ugettext_lazy as _ -from machine.machine_type import BaseDriver, BaseMachineType, MachineStatus - -class BaseXYZDriver(BaseDriver): - """Base xyz driver.""" - - machine_type = 'xyz' - - def my_custom_required_method(self): - """This function must be overridden.""" - raise NotImplementedError('The `my_custom_required_method` function must be overridden!') - - def my_custom_method(self): - """This function must be overridden.""" - raise NotImplementedError('The `my_custom_method` function must be overridden!') - - requires_override = [my_custom_required_method] - -class XYZMachineType(BaseMachineType): - SLUG = 'xyz' - NAME = _('XYZ') - DESCRIPTION = _('This is an awesome machine type for xyz.') - - base_driver = BaseXYZDriver - - class XYZStatus(MachineStatus): - CONNECTED = 100, _('Connected'), 'success' - STANDBY = 101, _('Standby'), 'success' - PRINTING = 110, _('Printing'), 'primary' - - MACHINE_STATUS = XYZStatus - - default_machine_status = XYZStatus.DISCONNECTED -``` - -### Drivers - -Drivers provide the connection layer between physical machines and inventree. There can be multiple drivers defined for the same machine type. Drivers are provided by plugins that are enabled and extend the corresponding base driver for the particular machine type. Each machine type already provides a base driver that needs to be inherited. - -#### Available attributes - -| Name | Description | -| ------------------ | --------------------------------------------------------------------------------------------------------------- | -| `SLUG` | A slug for the driver needs to be set (short identifier string that satisfies the following format `/[a-z-]+/`) | -| `NAME` | A name for the driver needs to be set | -| `DESCRIPTION` | A description for the driver needs to be set | -| `MACHINE_SETTINGS` | Machine settings dict, see [settings](#settings) (optional) | -| (`machine_type`) | Already set to the machine type slug by the base driver | - -#### Available methods - -| Name | Description | -| ------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | -| `init_driver(self)` | gets called on initialization before each individual machine gets initialized | -| `init_machine(self, machine: 'BaseMachineType')` | gets called for each machine | -| `update_machine(self, old_machine_state: Dict[str, Any], machine: 'BaseMachineType')` | gets called if any changes on a machine or their settings occur. The `machine.restart_required` flag can be set here. | -| `restart_machine(self, machine: 'BaseMachineType')` | gets called if the admin manually presses the restart button on the admin interface | -| `get_machines(self, *, name, machine_type, driver, initialized, active, base_driver)` | helper function to get all machine, by default only initialized machines that also use the current driver are returned | -| `handle_error(self, error: Union[Exception, str])` | helper function to capture errors and show them in the admin ui | - -#### Example driver - -A basic driver only needs to specify the basic attributes like `SLUG`, `NAME`, `DESCRIPTION`. The others are given by the used base driver, so take a look at [Machine types](#machine-types). The class will be discovered if it is provided by an **installed & activated** plugin just like this: - -```py -from plugin import InvenTreePlugin -from plugin.machine import BaseXYZDriver - -class CupsLabelPlugin(InvenTreePlugin): - NAME = "CupsLabels" - SLUG = "cups" - TITLE = "Cups Label Printer" - # ... - -class MyXYZDriver(BaseXYZDriver): - SLUG = 'my-abc-driver' - NAME = 'My ABC driver' - DESCRIPTION = 'This is an awesome driver for ABC' -``` - -### Settings - -Each machine can have different settings configured. There are machine settings that are specific to that machine type and driver settings that are specific to the driver, but both can be specified individually for each machine. Define them by adding a `MACHINE_SETTINGS` dictionary attribute to either the driver or the machine type. The format follows the same pattern as the `SETTINGS` for normal plugins documented on the [`SettingsMixin`](../plugins/settings.md) - -```py -class MyXYZDriver(BaseXYZDriver): - MACHINE_SETTINGS = { - 'SERVER': { - 'name': _('Server'), - 'description': _('IP/Hostname to connect to the cups server'), - 'default': 'localhost', - 'required': True, - } - } -``` - -Settings can even marked as `'required': True` which prevents the machine from starting if the setting is not defined. - -### Machine status - -Each machine type has a set of status codes defined that can be set for each machine by the driver. There also needs to be a default status code defined. - -```py -class XYZMachineType(BaseMachineType): - # ... - class XYZStatus(MachineStatus): - CONNECTED = 100, _('Connected'), 'success' - STANDBY = 101, _('Standby'), 'success' - DISCONNECTED = 400, _('Disconnected'), 'danger' - - MACHINE_STATUS = XYZStatus - default_machine_status = XYZStatus.DISCONNECTED -``` - -And to set a status code for a machine by the driver. There can also be a free text status code defined. - -```py -class MyXYZDriver(BaseXYZDriver): - # ... - def init_machine(self, machine): - # ... do some init stuff here - machine.set_status(XYZMachineType.MACHINE_STATUS.CONNECTED) - machine.set_status_text("Paper missing") -``` diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 4ad2dffab96..57b31a786a3 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -201,7 +201,6 @@ nav: - Developing a Plugin: extend/how_to_plugin.md - Model Metadata: extend/plugins/metadata.md - Tags: extend/plugins/tags.md - - Machines: extend/plugins/machines.md - Plugin Mixins: - Action Mixin: extend/plugins/action.md - API Mixin: extend/plugins/api.md @@ -218,6 +217,9 @@ nav: - Settings Mixin: extend/plugins/settings.md - URL Mixin: extend/plugins/urls.md - Validation Mixin: extend/plugins/validation.md + - Machines: + - Overview: extend/machines/overview.md + - Label Printer: extend/machines/label_printer.md - Themes: extend/themes.md - Third-Party: extend/integrate.md @@ -233,6 +235,13 @@ plugins: on_config: "docs.docs.hooks:on_config" - macros: include_dir: docs/_includes + - mkdocstrings: + default_handler: python + handlers: + python: + options: + show_symbol_type_heading: true + show_symbol_type_toc: true # Extensions markdown_extensions: diff --git a/docs/requirements.txt b/docs/requirements.txt index 2bfb42c832a..ad768a9899a 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -4,3 +4,4 @@ mkdocs-material>=9.0,<10.0 mkdocs-git-revision-date-localized-plugin>=1.1,<2.0 mkdocs-simple-hooks>=0.1,<1.0 mkdocs-include-markdown-plugin +mkdocstrings[python]>=0.24.0 From b8090b96a7da2a358126bd884618525318d9c59e Mon Sep 17 00:00:00 2001 From: wolflu05 <76838159+wolflu05@users.noreply.github.com> Date: Sun, 4 Feb 2024 15:53:32 +0000 Subject: [PATCH 65/86] Fix docs --- docs/mkdocs.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 57b31a786a3..3562e9a570a 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -239,6 +239,8 @@ plugins: default_handler: python handlers: python: + paths: + - ../InvenTree options: show_symbol_type_heading: true show_symbol_type_toc: true From ff0f02d06a7426bde6fb48b4e8c87c2ccad0fd4f Mon Sep 17 00:00:00 2001 From: wolflu05 <76838159+wolflu05@users.noreply.github.com> Date: Mon, 5 Feb 2024 08:35:00 +0000 Subject: [PATCH 66/86] Added UNKNOWN status code to label printer status --- InvenTree/machine/machine_type.py | 3 +++ InvenTree/machine/machine_types/LabelPrintingMachineType.py | 6 +++--- docs/docs/extend/machines/label_printer.md | 2 +- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/InvenTree/machine/machine_type.py b/InvenTree/machine/machine_type.py index 00fa66ba14b..fd1e052d262 100644 --- a/InvenTree/machine/machine_type.py +++ b/InvenTree/machine/machine_type.py @@ -34,6 +34,9 @@ class MachineStatus(StatusCode): Additionally there are helpers to access all additional attributes `text`, `label`, `color`. + Available colors: + primary, secondary, warning, danger, success, warning, info + Status code ranges: ``` 1XX - Everything fine diff --git a/InvenTree/machine/machine_types/LabelPrintingMachineType.py b/InvenTree/machine/machine_types/LabelPrintingMachineType.py index 936430f4d9d..4d8679eb960 100644 --- a/InvenTree/machine/machine_types/LabelPrintingMachineType.py +++ b/InvenTree/machine/machine_types/LabelPrintingMachineType.py @@ -221,14 +221,14 @@ class LabelPrinterStatus(MachineStatus): Attributes: CONNECTED: The printer is connected and ready to print - STANDBY: The printers connection is in standby mode, but will be ready to print at any time + UNKNOWN: The printer status is unknown (e.g. there is no active connection to the printer) PRINTING: The printer is currently printing a label NO_MEDIA: The printer is out of media (e.g. the label spool is empty) DISCONNECTED: The driver cannot establish a connection to the printer """ CONNECTED = 100, _('Connected'), 'success' - UNKNOWN = 101, _('Standby'), 'success' + UNKNOWN = 101, _('Unknown'), 'secondary' PRINTING = 110, _('Printing'), 'primary' NO_MEDIA = 301, _('No media'), 'warning' DISCONNECTED = 400, _('Disconnected'), 'danger' @@ -253,7 +253,7 @@ class LabelPrintingMachineType(BaseMachineType): MACHINE_STATUS = LabelPrinterStatus - default_machine_status = LabelPrinterStatus.DISCONNECTED + default_machine_status = LabelPrinterStatus.UNKNOWN @property def location(self): diff --git a/docs/docs/extend/machines/label_printer.md b/docs/docs/extend/machines/label_printer.md index ea49dadb9d7..00e3bef00f0 100644 --- a/docs/docs/extend/machines/label_printer.md +++ b/docs/docs/extend/machines/label_printer.md @@ -8,7 +8,7 @@ Take a look at the most basic required code for a driver in this [example](./ove ### Label printer status -There are a couple of predefined status codes for label printers. By default the Disconnected status code is set. +There are a couple of predefined status codes for label printers. By default the `UNKNOWN` status code is set for each machine, but they can be changed at any time by the driver. For more info about status code see [Machine status codes](./overview.md#machine-status). ::: machine.machine_types.LabelPrintingMachineType.LabelPrinterStatus options: From 71761cdfd12e7c2f6dd6c15024f08bdf94e59e35 Mon Sep 17 00:00:00 2001 From: wolflu05 <76838159+wolflu05@users.noreply.github.com> Date: Mon, 5 Feb 2024 08:54:51 +0000 Subject: [PATCH 67/86] Skip machine loading when running migrations --- InvenTree/machine/apps.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/InvenTree/machine/apps.py b/InvenTree/machine/apps.py index 602c2500b02..217afdb0927 100755 --- a/InvenTree/machine/apps.py +++ b/InvenTree/machine/apps.py @@ -4,7 +4,12 @@ from django.apps import AppConfig -from InvenTree.ready import canAppAccessDatabase, isInMainThread, isPluginRegistryLoaded +from InvenTree.ready import ( + canAppAccessDatabase, + isInMainThread, + isPluginRegistryLoaded, + isRunningMigrations, +) logger = logging.getLogger('inventree') @@ -20,6 +25,7 @@ def ready(self) -> None: not canAppAccessDatabase(allow_test=True) or not isPluginRegistryLoaded() or not isInMainThread() + or isRunningMigrations() ): logger.debug('Machine app: Skipping machine loading sequence') return From 60fbb85b01506cf67d92e29cc7dd371df7148231 Mon Sep 17 00:00:00 2001 From: wolflu05 <76838159+wolflu05@users.noreply.github.com> Date: Mon, 5 Feb 2024 09:10:13 +0000 Subject: [PATCH 68/86] Fix testing? --- InvenTree/machine/apps.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/InvenTree/machine/apps.py b/InvenTree/machine/apps.py index 217afdb0927..3264ac6319c 100755 --- a/InvenTree/machine/apps.py +++ b/InvenTree/machine/apps.py @@ -3,9 +3,11 @@ import logging from django.apps import AppConfig +from django.db.utils import OperationalError, ProgrammingError from InvenTree.ready import ( canAppAccessDatabase, + isImportingData, isInMainThread, isPluginRegistryLoaded, isRunningMigrations, @@ -26,11 +28,16 @@ def ready(self) -> None: or not isPluginRegistryLoaded() or not isInMainThread() or isRunningMigrations() + or isImportingData() ): logger.debug('Machine app: Skipping machine loading sequence') return from machine import registry - logger.info('Loading InvenTree machines') - registry.initialize() + try: + logger.info('Loading InvenTree machines') + registry.initialize() + except (OperationalError, ProgrammingError): + # Database might not yet be ready + logger.warn('Database was not ready for initializing machines') From 25a147e6446db5e315f047311c6914eff28c5dcd Mon Sep 17 00:00:00 2001 From: wolflu05 <76838159+wolflu05@users.noreply.github.com> Date: Mon, 5 Feb 2024 10:27:31 +0000 Subject: [PATCH 69/86] Fix: tests? --- InvenTree/plugin/base/label/mixins.py | 2 +- InvenTree/plugin/base/label/test_label_mixin.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/InvenTree/plugin/base/label/mixins.py b/InvenTree/plugin/base/label/mixins.py index daef8d590c2..ff2e292dd59 100644 --- a/InvenTree/plugin/base/label/mixins.py +++ b/InvenTree/plugin/base/label/mixins.py @@ -126,7 +126,7 @@ def print_labels( 'user': user, 'width': label.width, 'height': label.height, - 'printing_options': printing_options, + 'printing_options': kwargs['printing_options'], } if self.BLOCKING_PRINT: diff --git a/InvenTree/plugin/base/label/test_label_mixin.py b/InvenTree/plugin/base/label/test_label_mixin.py index dbb73713f52..9dfb2f6dc3c 100644 --- a/InvenTree/plugin/base/label/test_label_mixin.py +++ b/InvenTree/plugin/base/label/test_label_mixin.py @@ -82,7 +82,7 @@ def test_installed(self): """Test that the sample printing plugin is installed.""" # Get all label plugins plugins = registry.with_mixin('labels') - self.assertEqual(len(plugins), 3) + self.assertEqual(len(plugins), 4) # But, it is not 'active' plugins = registry.with_mixin('labels', active=True) @@ -110,7 +110,7 @@ def test_api(self): # Should be available via the API now response = self.client.get(url, {'mixin': 'labels', 'active': True}) - self.assertEqual(len(response.data), 3) + self.assertEqual(len(response.data), 4) labels = [item['key'] for item in response.data] From 568f76af05a2e03074c5dd23cf3b8fa4005b481f Mon Sep 17 00:00:00 2001 From: wolflu05 <76838159+wolflu05@users.noreply.github.com> Date: Mon, 5 Feb 2024 10:52:09 +0000 Subject: [PATCH 70/86] Fix: tests? --- InvenTree/plugin/base/label/test_label_mixin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/InvenTree/plugin/base/label/test_label_mixin.py b/InvenTree/plugin/base/label/test_label_mixin.py index 9dfb2f6dc3c..240ec409d54 100644 --- a/InvenTree/plugin/base/label/test_label_mixin.py +++ b/InvenTree/plugin/base/label/test_label_mixin.py @@ -86,7 +86,7 @@ def test_installed(self): # But, it is not 'active' plugins = registry.with_mixin('labels', active=True) - self.assertEqual(len(plugins), 2) + self.assertEqual(len(plugins), 3) def test_api(self): """Test that we can filter the API endpoint by mixin.""" From dbdd364289f7b7f17f6b7a11f6ad5c47e5ca4a29 Mon Sep 17 00:00:00 2001 From: wolflu05 <76838159+wolflu05@users.noreply.github.com> Date: Mon, 5 Feb 2024 11:30:28 +0000 Subject: [PATCH 71/86] Disable docs check precommit --- docs/ci/check_mkdocs_config.py | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/ci/check_mkdocs_config.py b/docs/ci/check_mkdocs_config.py index 93a3afe0c80..6658b74e0af 100644 --- a/docs/ci/check_mkdocs_config.py +++ b/docs/ci/check_mkdocs_config.py @@ -1,5 +1,6 @@ """Check mkdocs.yml config file for errors.""" +exit(0) import os import yaml From a3966c22b43b6ab138520a3245d6006e8f73b453 Mon Sep 17 00:00:00 2001 From: wolflu05 <76838159+wolflu05@users.noreply.github.com> Date: Mon, 5 Feb 2024 11:34:14 +0000 Subject: [PATCH 72/86] Disable docs check precommit --- .pre-commit-config.yaml | 2 +- docs/ci/check_mkdocs_config.py | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 764691ede0c..418349912b1 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -13,7 +13,7 @@ repos: hooks: - id: trailing-whitespace - id: end-of-file-fixer - - id: check-yaml + # - id: check-yaml - id: mixed-line-ending - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.1.11 diff --git a/docs/ci/check_mkdocs_config.py b/docs/ci/check_mkdocs_config.py index 6658b74e0af..93a3afe0c80 100644 --- a/docs/ci/check_mkdocs_config.py +++ b/docs/ci/check_mkdocs_config.py @@ -1,6 +1,5 @@ """Check mkdocs.yml config file for errors.""" -exit(0) import os import yaml From 55508b88ecb67b9e56b12214242af0a607feb70e Mon Sep 17 00:00:00 2001 From: wolflu05 <76838159+wolflu05@users.noreply.github.com> Date: Mon, 5 Feb 2024 14:25:59 +0000 Subject: [PATCH 73/86] First draft for tests --- .../machine_types/LabelPrintingMachineType.py | 4 +- InvenTree/machine/test_api.py | 67 +++++++++++++++++++ 2 files changed, 69 insertions(+), 2 deletions(-) create mode 100644 InvenTree/machine/test_api.py diff --git a/InvenTree/machine/machine_types/LabelPrintingMachineType.py b/InvenTree/machine/machine_types/LabelPrintingMachineType.py index 4d8679eb960..fcedea92a04 100644 --- a/InvenTree/machine/machine_types/LabelPrintingMachineType.py +++ b/InvenTree/machine/machine_types/LabelPrintingMachineType.py @@ -24,7 +24,7 @@ class BaseLabelPrintingDriver(BaseDriver): USE_BACKGROUND_WORKER (bool): If True, the `print_label()` and `print_labels()` methods will be run in a background worker (default: True) """ - machine_type = 'label_printer' + machine_type = 'label-printer' USE_BACKGROUND_WORKER = True @@ -237,7 +237,7 @@ class LabelPrinterStatus(MachineStatus): class LabelPrintingMachineType(BaseMachineType): """Label printer machine type, is a direct integration to print labels for various items.""" - SLUG = 'label_printer' + SLUG = 'label-printer' NAME = _('Label Printer') DESCRIPTION = _('Directly print labels for various items.') diff --git a/InvenTree/machine/test_api.py b/InvenTree/machine/test_api.py new file mode 100644 index 00000000000..e5950375657 --- /dev/null +++ b/InvenTree/machine/test_api.py @@ -0,0 +1,67 @@ +"""Machine API tests.""" + +from django.urls import reverse + +from InvenTree.unit_test import InvenTreeAPITestCase +from machine import registry +from machine.machine_types import BaseLabelPrintingDriver + + +class MachineAPITest(InvenTreeAPITestCase): + """Class for unit testing machine API endpoints.""" + + @classmethod + def setUpTestData(cls): + """Create a test driver.""" + super().setUpTestData() + + class TestingLabelPrinterDriver(BaseLabelPrintingDriver): + """Test driver for label printing.""" + + SLUG = 'test-label-printer' + NAME = 'Test label printer' + DESCRIPTION = 'This is a test label printer driver for testing.' + + def print_label(self, *args, **kwargs) -> None: + """Override print_label.""" + pass + + id(TestingLabelPrinterDriver) # just to be sure that this class really exists + registry.initialize() + + def test_machine_type_list(self): + """Test machine types list API endpoint.""" + response = self.get(reverse('api-machine-types')) + self.assertEqual(len(response.data), 1) + self.assertDictContainsSubset( + { + 'slug': 'label-printer', + 'name': 'Label Printer', + 'description': 'Directly print labels for various items.', + 'provider_plugin': None, + 'is_builtin': True, + }, + response.data[0], + ) + self.assertTrue( + response.data[0]['provider_file'].endswith( + 'machine/machine_types/LabelPrintingMachineType.py' + ) + ) + + def test_machine_driver_list(self): + """Test machine driver list API endpoint.""" + response = self.get(reverse('api-machine-drivers')) + self.assertEqual(len(response.data), 1) + self.assertDictContainsSubset( + { + 'slug': 'test-label-printer', + 'name': 'Test label printer', + 'description': 'This is a test label printer driver for testing.', + 'provider_plugin': None, + 'is_builtin': True, + 'machine_type': 'label-printer', + 'errors': [], + }, + response.data[0], + ) From 422a51fbdf06c1caefff8912d918992a5f4cd896 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Mon, 5 Feb 2024 20:50:45 +0100 Subject: [PATCH 74/86] fix test --- InvenTree/machine/test_api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/InvenTree/machine/test_api.py b/InvenTree/machine/test_api.py index e5950375657..fa825dc8548 100644 --- a/InvenTree/machine/test_api.py +++ b/InvenTree/machine/test_api.py @@ -61,7 +61,7 @@ def test_machine_driver_list(self): 'provider_plugin': None, 'is_builtin': True, 'machine_type': 'label-printer', - 'errors': [], + 'driver_errors': [], }, response.data[0], ) From 138a1542c8f1114a664f12e8debf05a6cf4c2b56 Mon Sep 17 00:00:00 2001 From: wolflu05 <76838159+wolflu05@users.noreply.github.com> Date: Mon, 5 Feb 2024 20:21:48 +0000 Subject: [PATCH 75/86] Add type ignore --- InvenTree/machine/machine_types/LabelPrintingMachineType.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/InvenTree/machine/machine_types/LabelPrintingMachineType.py b/InvenTree/machine/machine_types/LabelPrintingMachineType.py index fcedea92a04..9e2bfcffbfb 100644 --- a/InvenTree/machine/machine_types/LabelPrintingMachineType.py +++ b/InvenTree/machine/machine_types/LabelPrintingMachineType.py @@ -113,7 +113,7 @@ def get_printing_options_serializer( Returns: A class instance of a DRF serializer class, by default this an instance of self.PrintingOptionsSerializer using the *args, **kwargs if existing for this driver """ - return self.PrintingOptionsSerializer(*args, **kwargs) + return self.PrintingOptionsSerializer(*args, **kwargs) # type: ignore # --- helper functions @property From 8ffd865a9cdad9a371a2504bf3034a372e974596 Mon Sep 17 00:00:00 2001 From: wolflu05 <76838159+wolflu05@users.noreply.github.com> Date: Tue, 6 Feb 2024 12:33:41 +0000 Subject: [PATCH 76/86] Added API tests --- InvenTree/InvenTree/tests.py | 96 +++++++++ InvenTree/machine/test_api.py | 195 +++++++++++++++++- .../src/tables/machine/MachineListTable.tsx | 2 +- 3 files changed, 288 insertions(+), 5 deletions(-) diff --git a/InvenTree/InvenTree/tests.py b/InvenTree/InvenTree/tests.py index 0cda36703b6..fc417be9da3 100644 --- a/InvenTree/InvenTree/tests.py +++ b/InvenTree/InvenTree/tests.py @@ -28,6 +28,7 @@ import InvenTree.tasks from common.models import CustomUnit, InvenTreeSetting from common.settings import currency_codes +from InvenTree.helpers_mixin import ClassProviderMixin, ClassValidationMixin from InvenTree.sanitizer import sanitize_svg from InvenTree.unit_test import InvenTreeTestCase from part.models import Part, PartCategory @@ -1264,3 +1265,98 @@ def test_generation(self): self.assertEqual(resp.url, '/api/auth/login-redirect/') # And we should be logged in again self.assertEqual(resp.wsgi_request.user, self.user) + + +class ClassValidationMixinTest(TestCase): + """Tests for the ClassValidationMixin class.""" + + class BaseTestClass(ClassValidationMixin): + """A valid class that inherits from ClassValidationMixin.""" + + NAME: str + + def test(self): + """Test function.""" + pass + + def test1(self): + """Test function.""" + pass + + def test2(self): + """Test function.""" + pass + + required_attributes = ['NAME'] + required_overrides = [test, [test1, test2]] + + class InvalidClass: + """An invalid class that does not inherit from ClassValidationMixin.""" + + pass + + def test_valid_class(self): + """Test that a valid class passes the validation.""" + + class TestClass(self.BaseTestClass): + """A valid class that inherits from BaseTestClass.""" + + NAME = 'Test' + + def test(self): + """Test function.""" + pass + + def test2(self): + """Test function.""" + pass + + TestClass.validate() + + def test_invalid_class(self): + """Test that an invalid class fails the validation.""" + + class TestClass1(self.BaseTestClass): + """A bad class that inherits from BaseTestClass.""" + + with self.assertRaisesRegex( + NotImplementedError, + r'\'<.*TestClass1\'>\' did not provide the following attributes: NAME and did not override the required attributes: test, one of test1 or test2', + ): + TestClass1.validate() + + class TestClass2(self.BaseTestClass): + """A bad class that inherits from BaseTestClass.""" + + NAME = 'Test' + + def test2(self): + """Test function.""" + pass + + with self.assertRaisesRegex( + NotImplementedError, + r'\'<.*TestClass2\'>\' did not override the required attributes: test', + ): + TestClass2.validate() + + +class ClassProviderMixinTest(TestCase): + """Tests for the ClassProviderMixin class.""" + + class TestClass(ClassProviderMixin): + """This class is a dummy class to test the ClassProviderMixin.""" + + pass + + def test_get_provider_file(self): + """Test the get_provider_file function.""" + self.assertEqual(self.TestClass.get_provider_file(), __file__) + + def test_provider_plugin(self): + """Test the provider_plugin function.""" + self.assertEqual(self.TestClass.get_provider_plugin(), None) + + def test_get_is_builtin(self): + """Test the get_is_builtin function.""" + self.assertTrue(self.TestClass.get_is_builtin()) diff --git a/InvenTree/machine/test_api.py b/InvenTree/machine/test_api.py index fa825dc8548..261d7728f6f 100644 --- a/InvenTree/machine/test_api.py +++ b/InvenTree/machine/test_api.py @@ -4,16 +4,20 @@ from InvenTree.unit_test import InvenTreeAPITestCase from machine import registry +from machine.machine_type import BaseMachineType from machine.machine_types import BaseLabelPrintingDriver +from machine.models import MachineConfig +from stock.models import StockLocation class MachineAPITest(InvenTreeAPITestCase): """Class for unit testing machine API endpoints.""" + roles = ['admin.add', 'admin.view', 'admin.change', 'admin.delete'] + @classmethod - def setUpTestData(cls): - """Create a test driver.""" - super().setUpTestData() + def setUpClass(cls): + """Setup some testing drivers/machines.""" class TestingLabelPrinterDriver(BaseLabelPrintingDriver): """Test driver for label printing.""" @@ -22,13 +26,43 @@ class TestingLabelPrinterDriver(BaseLabelPrintingDriver): 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.""" + pass + + class CopyTestingLabelPrinterDriver(BaseLabelPrintingDriver): + """Test driver for label printing.""" + + SLUG = 'test-label-printer' + 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 print_label(self, *args, **kwargs) -> None: """Override print_label.""" pass - id(TestingLabelPrinterDriver) # just to be sure that this class really exists registry.initialize() + super().setUpClass() + def test_machine_type_list(self): """Test machine types list API endpoint.""" response = self.get(reverse('api-machine-types')) @@ -65,3 +99,156 @@ def test_machine_driver_list(self): }, response.data[0], ) + + def test_machine_status(self): + """Test machine status API endpoint.""" + response = self.get(reverse('api-machine-registry-status')) + self.assertIn( + "Cannot re-register driver 'test-label-printer'", + [e['message'] for e in response.data['registry_errors']], + ) + + def test_machine_list(self): + """Test machine list API endpoint.""" + response = self.get(reverse('api-machine-list')) + self.assertEqual(len(response.data), 0) + + MachineConfig.objects.create( + machine_type='label-printer', + driver='test-label-printer', + name='Test Machine', + active=True, + ) + + response = self.get(reverse('api-machine-list')) + self.assertEqual(len(response.data), 1) + self.assertDictContainsSubset( + { + 'name': 'Test Machine', + 'machine_type': 'label-printer', + 'driver': 'test-label-printer', + 'initialized': True, + 'active': True, + 'status': 101, + 'status_model': 'LabelPrinterStatus', + 'status_text': '', + 'is_driver_available': True, + }, + response.data[0], + ) + + def test_machine_detail(self): + """Test machine detail API endpoint.""" + placeholder_uuid = '00000000-0000-0000-0000-000000000000' + self.assertFalse(len(MachineConfig.objects.all()), 0) + self.get( + reverse('api-machine-detail', kwargs={'pk': placeholder_uuid}), + expected_code=404, + ) + + machine_data = { + 'machine_type': 'label-printer', + 'driver': 'test-label-printer', + 'name': 'Test Machine', + 'active': True, + } + + # Create a machine + response = self.post(reverse('api-machine-list'), machine_data) + self.assertDictContainsSubset(machine_data, response.data) + pk = response.data['pk'] + + # Retrieve the machine + response = self.get(reverse('api-machine-detail', kwargs={'pk': pk})) + self.assertDictContainsSubset(machine_data, response.data) + + # Update the machine + response = self.patch( + reverse('api-machine-detail', kwargs={'pk': pk}), + {'name': 'Updated Machine'}, + ) + self.assertDictContainsSubset({'name': 'Updated Machine'}, response.data) + self.assertEqual(MachineConfig.objects.get(pk=pk).name, 'Updated Machine') + + # Delete the machine + response = self.delete( + reverse('api-machine-detail', kwargs={'pk': pk}), expected_code=204 + ) + self.assertFalse(len(MachineConfig.objects.all()), 0) + + # Create machine where the driver does not exist + machine_data['driver'] = 'non-existent-driver' + machine_data['name'] = 'Machine with non-existent driver' + response = self.post(reverse('api-machine-list'), machine_data) + self.assertIn( + "Driver 'non-existent-driver' not found", response.data['machine_errors'] + ) + self.assertFalse(response.data['initialized']) + self.assertFalse(response.data['is_driver_available']) + + def test_machine_detail_settings(self): + """Test machine detail settings API endpoint.""" + machine = MachineConfig.objects.create( + machine_type='label-printer', + driver='test-label-printer', + name='Test Machine with settings', + active=True, + ) + machine_setting_url = reverse( + 'api-machine-settings-detail', + kwargs={'pk': machine.pk, 'config_type': 'M', 'key': 'LOCATION'}, + ) + driver_setting_url = reverse( + 'api-machine-settings-detail', + kwargs={'pk': machine.pk, 'config_type': 'D', 'key': 'TEST_SETTING'}, + ) + + # Get settings + response = self.get(machine_setting_url) + self.assertEqual(response.data['value'], '') + + response = self.get(driver_setting_url) + self.assertEqual(response.data['value'], '') + + # Update machine setting + location = StockLocation.objects.create(name='Test Location') + response = self.patch(machine_setting_url, {'value': str(location.pk)}) + self.assertEqual(response.data['value'], str(location.pk)) + + response = self.get(machine_setting_url) + self.assertEqual(response.data['value'], str(location.pk)) + + # Update driver setting + response = self.patch(driver_setting_url, {'value': 'test value'}) + self.assertEqual(response.data['value'], 'test value') + + response = self.get(driver_setting_url) + self.assertEqual(response.data['value'], 'test value') + + # Get list of all settings for a machine + settings_url = reverse('api-machine-settings', kwargs={'pk': machine.pk}) + response = self.get(settings_url) + self.assertEqual(len(response.data), 2) + self.assertEqual( + [('M', 'LOCATION'), ('D', 'TEST_SETTING')], + [(s['config_type'], s['key']) for s in response.data], + ) + + def test_machine_restart(self): + """Test machine restart API endpoint.""" + machine = MachineConfig.objects.create( + machine_type='label-printer', + driver='test-label-printer', + name='Test Machine', + active=True, + ) + + response = self.get(reverse('api-machine-detail', kwargs={'pk': machine.pk})) + self.assertEqual(response.data['status_text'], '') + + response = self.post( + reverse('api-machine-restart', kwargs={'pk': machine.pk}), expected_code=200 + ) + + response = self.get(reverse('api-machine-detail', kwargs={'pk': machine.pk})) + self.assertEqual(response.data['status_text'], 'Restarting...') diff --git a/src/frontend/src/tables/machine/MachineListTable.tsx b/src/frontend/src/tables/machine/MachineListTable.tsx index 390e493d91a..813d7842553 100644 --- a/src/frontend/src/tables/machine/MachineListTable.tsx +++ b/src/frontend/src/tables/machine/MachineListTable.tsx @@ -405,7 +405,7 @@ export function MachineListTable({ sortable: true, render: function (record) { return ( - + {record.name} {record.restart_required && ( From 32dc782beb42d887847d924d4e06e07f18600df2 Mon Sep 17 00:00:00 2001 From: wolflu05 <76838159+wolflu05@users.noreply.github.com> Date: Tue, 6 Feb 2024 13:33:16 +0000 Subject: [PATCH 77/86] Test ci? --- InvenTree/machine/test_api.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/InvenTree/machine/test_api.py b/InvenTree/machine/test_api.py index 261d7728f6f..c195a795c28 100644 --- a/InvenTree/machine/test_api.py +++ b/InvenTree/machine/test_api.py @@ -243,12 +243,15 @@ def test_machine_restart(self): active=True, ) + # verify machine status before restart response = self.get(reverse('api-machine-detail', kwargs={'pk': machine.pk})) self.assertEqual(response.data['status_text'], '') + # restart the machine response = self.post( reverse('api-machine-restart', kwargs={'pk': machine.pk}), expected_code=200 ) + # verify machine status after restart response = self.get(reverse('api-machine-detail', kwargs={'pk': machine.pk})) self.assertEqual(response.data['status_text'], 'Restarting...') From 7cfe1e83ed3ec0c1774fc7a17c92a3f0a5a102e8 Mon Sep 17 00:00:00 2001 From: wolflu05 <76838159+wolflu05@users.noreply.github.com> Date: Tue, 6 Feb 2024 21:11:08 +0000 Subject: [PATCH 78/86] Add more tests --- InvenTree/machine/test_api.py | 71 +++++++++----- InvenTree/machine/tests.py | 174 +++++++++++++++++++++++++++++++++- 2 files changed, 218 insertions(+), 27 deletions(-) diff --git a/InvenTree/machine/test_api.py b/InvenTree/machine/test_api.py index c195a795c28..0cad560e639 100644 --- a/InvenTree/machine/test_api.py +++ b/InvenTree/machine/test_api.py @@ -15,14 +15,14 @@ class MachineAPITest(InvenTreeAPITestCase): roles = ['admin.add', 'admin.view', 'admin.change', 'admin.delete'] - @classmethod - def setUpClass(cls): + # @classmethod + def setUp(self): """Setup some testing drivers/machines.""" class TestingLabelPrinterDriver(BaseLabelPrintingDriver): """Test driver for label printing.""" - SLUG = 'test-label-printer' + SLUG = 'test-label-printer-api' NAME = 'Test label printer' DESCRIPTION = 'This is a test label printer driver for testing.' @@ -41,19 +41,23 @@ def print_label(self, *args, **kwargs) -> None: """Override print_label.""" pass - class CopyTestingLabelPrinterDriver(BaseLabelPrintingDriver): + class TestingLabelPrinterDriverError1(BaseLabelPrintingDriver): """Test driver for label printing.""" - SLUG = 'test-label-printer' - NAME = 'Test label printer' + SLUG = 'test-label-printer-error' + NAME = 'Test label printer error' DESCRIPTION = 'This is a test label printer driver for testing.' - MACHINE_SETTINGS = { - 'TEST_SETTING': { - 'name': 'Test setting', - 'description': 'This is a test setting', - } - } + def print_label(self, *args, **kwargs) -> None: + """Override print_label.""" + pass + + class TestingLabelPrinterDriverError2(BaseLabelPrintingDriver): + """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.""" @@ -61,12 +65,26 @@ def print_label(self, *args, **kwargs) -> None: registry.initialize() - super().setUpClass() + super().setUp() + + # @classmethod + def tearDown(self) -> None: + """Clean up after testing.""" + registry.machine_types = {} + registry.drivers = {} + registry.driver_instances = {} + registry.machines = {} + registry.base_drivers = [] + registry.errors = [] + + return super().tearDown() def test_machine_type_list(self): """Test machine types list API endpoint.""" response = self.get(reverse('api-machine-types')) - self.assertEqual(len(response.data), 1) + machine_type = [t for t in response.data if t['slug'] == 'label-printer'] + self.assertEqual(len(machine_type), 1) + machine_type = machine_type[0] self.assertDictContainsSubset( { 'slug': 'label-printer', @@ -75,10 +93,10 @@ def test_machine_type_list(self): 'provider_plugin': None, 'is_builtin': True, }, - response.data[0], + machine_type, ) self.assertTrue( - response.data[0]['provider_file'].endswith( + machine_type['provider_file'].endswith( 'machine/machine_types/LabelPrintingMachineType.py' ) ) @@ -86,10 +104,12 @@ def test_machine_type_list(self): def test_machine_driver_list(self): """Test machine driver list API endpoint.""" response = self.get(reverse('api-machine-drivers')) - self.assertEqual(len(response.data), 1) + driver = [a for a in response.data if a['slug'] == 'test-label-printer-api'] + self.assertEqual(len(driver), 1) + driver = driver[0] self.assertDictContainsSubset( { - 'slug': 'test-label-printer', + 'slug': 'test-label-printer-api', 'name': 'Test label printer', 'description': 'This is a test label printer driver for testing.', 'provider_plugin': None, @@ -97,14 +117,15 @@ def test_machine_driver_list(self): 'machine_type': 'label-printer', 'driver_errors': [], }, - response.data[0], + driver, ) + self.assertEqual(driver['provider_file'], __file__) def test_machine_status(self): """Test machine status API endpoint.""" response = self.get(reverse('api-machine-registry-status')) self.assertIn( - "Cannot re-register driver 'test-label-printer'", + "Cannot re-register driver 'test-label-printer-error'", [e['message'] for e in response.data['registry_errors']], ) @@ -115,7 +136,7 @@ def test_machine_list(self): MachineConfig.objects.create( machine_type='label-printer', - driver='test-label-printer', + driver='test-label-printer-api', name='Test Machine', active=True, ) @@ -126,7 +147,7 @@ def test_machine_list(self): { 'name': 'Test Machine', 'machine_type': 'label-printer', - 'driver': 'test-label-printer', + 'driver': 'test-label-printer-api', 'initialized': True, 'active': True, 'status': 101, @@ -148,7 +169,7 @@ def test_machine_detail(self): machine_data = { 'machine_type': 'label-printer', - 'driver': 'test-label-printer', + 'driver': 'test-label-printer-api', 'name': 'Test Machine', 'active': True, } @@ -190,7 +211,7 @@ def test_machine_detail_settings(self): """Test machine detail settings API endpoint.""" machine = MachineConfig.objects.create( machine_type='label-printer', - driver='test-label-printer', + driver='test-label-printer-api', name='Test Machine with settings', active=True, ) @@ -238,7 +259,7 @@ def test_machine_restart(self): """Test machine restart API endpoint.""" machine = MachineConfig.objects.create( machine_type='label-printer', - driver='test-label-printer', + driver='test-label-printer-api', name='Test Machine', active=True, ) diff --git a/InvenTree/machine/tests.py b/InvenTree/machine/tests.py index 413d0d4bc7e..971d1bbf908 100755 --- a/InvenTree/machine/tests.py +++ b/InvenTree/machine/tests.py @@ -1,5 +1,175 @@ """Machine app tests.""" -# from django.test import TestCase +from unittest.mock import MagicMock, Mock -# Create your tests here. +from django.test import TestCase + +from machine.machine_type import BaseDriver, BaseMachineType, MachineStatus +from machine.models import MachineConfig +from machine.registry import registry + + +class TestDriverMachineInterface(TestCase): + """Test the machine registry.""" + + 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'} + } + + 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] + + # 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) + + # save machines + for m in self.machines: + m.save() + + # init registry + registry.initialize() + + # 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 tearDown(self) -> None: + """Clean up after testing.""" + registry.machine_types = {} + registry.drivers = {} + registry.driver_instances = {} + registry.machines = {} + registry.base_drivers = [] + registry.errors = [] + + return super().tearDown() + + def test_machine_lifecycle(self): + """Test the machine registry.""" + # 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(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 + ) + + # 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 + self.driver_mocks['init_driver'].assert_called_once() + self.assertEqual(self.driver_mocks['init_machine'].call_count, 2) + + # Test machine restart hook + registry.restart_machine(self.machine1.machine) + self.driver_mocks['restart_machine'].assert_called_once_with( + 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) + + # Test remove machine + self.assertEqual(len(registry.get_machines()), 2) + registry.remove_machine(machine1) + self.assertEqual(len(registry.get_machines()), 1) From be6eb54d7337b022552a6452e9ea6385bcfbce42 Mon Sep 17 00:00:00 2001 From: wolflu05 <76838159+wolflu05@users.noreply.github.com> Date: Wed, 7 Feb 2024 10:44:18 +0000 Subject: [PATCH 79/86] Added more tests --- .pre-commit-config.yaml | 2 +- InvenTree/machine/machine_type.py | 10 --- .../machine_types/LabelPrintingMachineType.py | 1 - InvenTree/machine/registry.py | 4 +- InvenTree/machine/serializers.py | 1 - InvenTree/machine/test_api.py | 77 +++++++++++++------ InvenTree/machine/tests.py | 68 ++++++++-------- 7 files changed, 96 insertions(+), 67 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 790aaf5df58..8c359ac93d6 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -13,7 +13,7 @@ repos: hooks: - id: trailing-whitespace - id: end-of-file-fixer - # - id: check-yaml + - id: check-yaml - id: mixed-line-ending - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.2.1 diff --git a/InvenTree/machine/machine_type.py b/InvenTree/machine/machine_type.py index fd1e052d262..f928d65735e 100644 --- a/InvenTree/machine/machine_type.py +++ b/InvenTree/machine/machine_type.py @@ -14,13 +14,9 @@ class MachineConfig: """Only used if not typechecking currently.""" - pass - class SettingsKeyType: """Only used if not typechecking currently.""" - pass - class MachineStatus(StatusCode): """Base class for representing a set of machine status codes. @@ -47,8 +43,6 @@ class MachineStatus(StatusCode): ``` """ - pass - class BaseDriver(ClassValidationMixin, ClassProviderMixin): """Base class for all machine drivers. @@ -83,7 +77,6 @@ def init_driver(self): After the driver is initialized, the self.init_machine function is called for each machine associated with that driver. """ - pass def init_machine(self, machine: 'BaseMachineType'): """This method gets called for each active machine using that driver while initialization. @@ -94,7 +87,6 @@ def init_machine(self, machine: 'BaseMachineType'): Arguments: machine: Machine instance """ - pass def update_machine( self, old_machine_state: Dict[str, Any], machine: 'BaseMachineType' @@ -108,7 +100,6 @@ def update_machine( old_machine_state: Dict holding the old machine state before update machine: Machine instance with the new state """ - pass def restart_machine(self, machine: 'BaseMachineType'): """This method gets called on manual machine restart e.g. by using the restart machine action in the Admin Center. @@ -119,7 +110,6 @@ def restart_machine(self, machine: 'BaseMachineType'): Arguments: machine: Machine instance """ - pass def get_machines(self, **kwargs): """Return all machines using this driver (By default only initialized machines). diff --git a/InvenTree/machine/machine_types/LabelPrintingMachineType.py b/InvenTree/machine/machine_types/LabelPrintingMachineType.py index 9e2bfcffbfb..b0a78cb484b 100644 --- a/InvenTree/machine/machine_types/LabelPrintingMachineType.py +++ b/InvenTree/machine/machine_types/LabelPrintingMachineType.py @@ -51,7 +51,6 @@ def print_label( Note that the supplied args/kwargs may be different if the driver overrides the print_labels() method. """ - pass def print_labels( self, diff --git a/InvenTree/machine/registry.py b/InvenTree/machine/registry.py index f949f299b88..3caa5e3178c 100644 --- a/InvenTree/machine/registry.py +++ b/InvenTree/machine/registry.py @@ -183,7 +183,9 @@ def get_machines(self, **kwargs): def filter_machine(machine: BaseMachineType): for key, value in kwargs.items(): if key not in allowed_fields: - continue + raise ValueError( + f"'{key}' is not a valid filter field for registry.get_machines." + ) # check if current driver is subclass from base_driver if key == 'base_driver': diff --git a/InvenTree/machine/serializers.py b/InvenTree/machine/serializers.py index 16e024aec07..88e87d2d181 100644 --- a/InvenTree/machine/serializers.py +++ b/InvenTree/machine/serializers.py @@ -57,7 +57,6 @@ def get_status_model(self, obj: MachineConfig) -> Union[str, None]: """Serializer method for the status model field.""" if obj.machine and obj.machine.MACHINE_STATUS: return obj.machine.MACHINE_STATUS.__name__ - return None def get_status_text(self, obj: MachineConfig) -> str: """Serializer method for the status text field.""" diff --git a/InvenTree/machine/test_api.py b/InvenTree/machine/test_api.py index 0cad560e639..c87f43c9c6f 100644 --- a/InvenTree/machine/test_api.py +++ b/InvenTree/machine/test_api.py @@ -1,21 +1,26 @@ """Machine API tests.""" +import re +from typing import cast + from django.urls import reverse from InvenTree.unit_test import InvenTreeAPITestCase from machine import registry -from machine.machine_type import BaseMachineType +from machine.machine_type import BaseDriver, BaseMachineType from machine.machine_types import BaseLabelPrintingDriver from machine.models import MachineConfig +from machine.tests import TestMachineRegistryMixin from stock.models import StockLocation -class MachineAPITest(InvenTreeAPITestCase): +class MachineAPITest(TestMachineRegistryMixin, InvenTreeAPITestCase): """Class for unit testing machine API endpoints.""" roles = ['admin.add', 'admin.view', 'admin.change', 'admin.delete'] - # @classmethod + placeholder_uuid = '00000000-0000-0000-0000-000000000000' + def setUp(self): """Setup some testing drivers/machines.""" @@ -39,7 +44,6 @@ def restart_machine(self, machine: BaseMachineType): def print_label(self, *args, **kwargs) -> None: """Override print_label.""" - pass class TestingLabelPrinterDriverError1(BaseLabelPrintingDriver): """Test driver for label printing.""" @@ -50,7 +54,6 @@ class TestingLabelPrinterDriverError1(BaseLabelPrintingDriver): def print_label(self, *args, **kwargs) -> None: """Override print_label.""" - pass class TestingLabelPrinterDriverError2(BaseLabelPrintingDriver): """Test driver for label printing.""" @@ -61,23 +64,17 @@ class TestingLabelPrinterDriverError2(BaseLabelPrintingDriver): def print_label(self, *args, **kwargs) -> None: """Override print_label.""" - pass - registry.initialize() + class TestingLabelPrinterDriverNotImplemented(BaseLabelPrintingDriver): + """Test driver for label printing.""" - super().setUp() + SLUG = 'test-label-printer-not-implemented' + NAME = 'Test label printer error not implemented' + DESCRIPTION = 'This is a test label printer driver for testing.' - # @classmethod - def tearDown(self) -> None: - """Clean up after testing.""" - registry.machine_types = {} - registry.drivers = {} - registry.driver_instances = {} - registry.machines = {} - registry.base_drivers = [] - registry.errors = [] + registry.initialize() - return super().tearDown() + super().setUp() def test_machine_type_list(self): """Test machine types list API endpoint.""" @@ -121,13 +118,38 @@ def test_machine_driver_list(self): ) self.assertEqual(driver['provider_file'], __file__) + # Test driver with errors + driver_instance = cast( + BaseDriver, registry.get_driver_instance('test-label-printer-api') + ) + self.assertIsNotNone(driver_instance) + driver_instance.handle_error('Test error') + + 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) + driver = driver[0] + self.assertEqual(driver['driver_errors'], ['Test error']) + def test_machine_status(self): """Test machine status API endpoint.""" response = self.get(reverse('api-machine-registry-status')) - self.assertIn( + errors_msgs = [e['message'] for e in response.data['registry_errors']] + + required_patterns = [ + r'\'\' did not override the required attributes: one of print_label or print_labels', "Cannot re-register driver 'test-label-printer-error'", - [e['message'] for e in response.data['registry_errors']], - ) + ] + + for pattern in required_patterns: + for error in errors_msgs: + if re.match(pattern, error): + break + else: + errors_str = '\n'.join([f'- {e}' for e in errors_msgs]) + self.fail( + f"""Error message matching pattern '{pattern}' not found in machine registry errors:\n{errors_str}""" + ) def test_machine_list(self): """Test machine list API endpoint.""" @@ -160,10 +182,9 @@ def test_machine_list(self): def test_machine_detail(self): """Test machine detail API endpoint.""" - placeholder_uuid = '00000000-0000-0000-0000-000000000000' self.assertFalse(len(MachineConfig.objects.all()), 0) self.get( - reverse('api-machine-detail', kwargs={'pk': placeholder_uuid}), + reverse('api-machine-detail', kwargs={'pk': self.placeholder_uuid}), expected_code=404, ) @@ -209,12 +230,22 @@ def test_machine_detail(self): def test_machine_detail_settings(self): """Test machine detail settings API endpoint.""" + machine_setting_url = reverse( + 'api-machine-settings-detail', + kwargs={'pk': self.placeholder_uuid, 'config_type': 'M', 'key': 'LOCATION'}, + ) + + # Test machine settings for non-existent machine + self.get(machine_setting_url, expected_code=404) + + # Create a machine machine = MachineConfig.objects.create( machine_type='label-printer', driver='test-label-printer-api', name='Test Machine with settings', active=True, ) + machine_setting_url = reverse( 'api-machine-settings-detail', kwargs={'pk': machine.pk, 'config_type': 'M', 'key': 'LOCATION'}, diff --git a/InvenTree/machine/tests.py b/InvenTree/machine/tests.py index 971d1bbf908..51e9b377ed5 100755 --- a/InvenTree/machine/tests.py +++ b/InvenTree/machine/tests.py @@ -9,7 +9,22 @@ from machine.registry import registry -class TestDriverMachineInterface(TestCase): +class TestMachineRegistryMixin(TestCase): + """Machine registry test mixin to setup the registry between tests correctly.""" + + def tearDown(self) -> None: + """Clean up after testing.""" + registry.machine_types = {} + registry.drivers = {} + registry.driver_instances = {} + registry.machines = {} + registry.base_drivers = [] + registry.errors = [] + + return super().tearDown() + + +class TestDriverMachineInterface(TestMachineRegistryMixin, TestCase): """Test the machine registry.""" def setUp(self): @@ -48,6 +63,20 @@ class TestingDriver(TestingMachineBaseDriver): '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', @@ -68,23 +97,6 @@ class TestingDriver(TestingMachineBaseDriver): ) self.machines = [self.machine1, self.machine2, self.machine3] - # 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) - - # save machines - for m in self.machines: - m.save() - # init registry registry.initialize() @@ -99,19 +111,8 @@ class TestingDriver(TestingMachineBaseDriver): super().setUp() - def tearDown(self) -> None: - """Clean up after testing.""" - registry.machine_types = {} - registry.drivers = {} - registry.driver_instances = {} - registry.machines = {} - registry.base_drivers = [] - registry.errors = [] - - return super().tearDown() - def test_machine_lifecycle(self): - """Test the machine registry.""" + """Test the machine lifecycle.""" # test that the registry is initialized correctly self.assertEqual(len(registry.machines), 3) self.assertEqual(len(registry.driver_instances), 1) @@ -127,6 +128,13 @@ def test_machine_lifecycle(self): len(registry.get_machines(name='Test Machine 1', active=True)), 1 ) + # test get_machines with an unknown filter + with self.assertRaisesMessage( + ValueError, + "'unknown_filter' is not a valid filter field for registry.get_machines.", + ): + registry.get_machines(unknown_filter='test') + # test get_machine self.assertEqual(registry.get_machine(self.machine1.pk), self.machine1.machine) From 17f8bb474e55924ce477082889fd52bbe23d08f9 Mon Sep 17 00:00:00 2001 From: wolflu05 <76838159+wolflu05@users.noreply.github.com> Date: Wed, 7 Feb 2024 11:03:57 +0000 Subject: [PATCH 80/86] Bump api version --- InvenTree/InvenTree/api_version.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/InvenTree/InvenTree/api_version.py b/InvenTree/InvenTree/api_version.py index 38d08a7bf79..41714859145 100644 --- a/InvenTree/InvenTree/api_version.py +++ b/InvenTree/InvenTree/api_version.py @@ -1,11 +1,20 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 166 +INVENTREE_API_VERSION = 167 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v167 -> 2024-02-07 : https://github.com/inventree/InvenTree/pull/4824 + - Adds machine CRUD API endpoints + - Adds machine settings API endpoints + - Adds machine restart API endpoint + - Adds machine types/drivers list API endpoints + - Adds machine registry status API endpoint + - Adds 'required' field to the global Settings API + - Discover sub-sub classes of the StatusCode API + v166 -> 2024-02-04 : https://github.com/inventree/InvenTree/pull/6400 - Adds package_name to plugin API - Adds mechanism for uninstalling plugins via the API From b4f6758350bfd367692d60655ebd41f793a31c3f Mon Sep 17 00:00:00 2001 From: wolflu05 <76838159+wolflu05@users.noreply.github.com> Date: Wed, 7 Feb 2024 12:03:10 +0000 Subject: [PATCH 81/86] Changed driver/base driver naming schema --- InvenTree/machine/machine_types/__init__.py | 10 +++--- ...rintingMachineType.py => label_printer.py} | 22 ++++++------- InvenTree/machine/test_api.py | 12 +++---- .../builtin/labels/inventree_machine.py | 12 +++---- docs/docs/extend/machines/label_printer.md | 6 ++-- docs/docs/extend/machines/overview.md | 31 +++++++++---------- 6 files changed, 46 insertions(+), 47 deletions(-) rename InvenTree/machine/machine_types/{LabelPrintingMachineType.py => label_printer.py} (94%) diff --git a/InvenTree/machine/machine_types/__init__.py b/InvenTree/machine/machine_types/__init__.py index f59f4adb4b8..6fe810f21a5 100644 --- a/InvenTree/machine/machine_types/__init__.py +++ b/InvenTree/machine/machine_types/__init__.py @@ -1,11 +1,11 @@ -from machine.machine_types.LabelPrintingMachineType import ( - BaseLabelPrintingDriver, - LabelPrintingMachineType, +from machine.machine_types.label_printer import ( + LabelPrinterBaseDriver, + LabelPrinterMachine, ) __all__ = [ # machine types - 'LabelPrintingMachineType', + 'LabelPrinterMachine', # base drivers - 'BaseLabelPrintingDriver', + 'LabelPrinterBaseDriver', ] diff --git a/InvenTree/machine/machine_types/LabelPrintingMachineType.py b/InvenTree/machine/machine_types/label_printer.py similarity index 94% rename from InvenTree/machine/machine_types/LabelPrintingMachineType.py rename to InvenTree/machine/machine_types/label_printer.py index b0a78cb484b..73fa57f2d79 100644 --- a/InvenTree/machine/machine_types/LabelPrintingMachineType.py +++ b/InvenTree/machine/machine_types/label_printer.py @@ -17,8 +17,8 @@ from stock.models import StockLocation -class BaseLabelPrintingDriver(BaseDriver): - """Base label printing driver. +class LabelPrinterBaseDriver(BaseDriver): + """Base driver for label printer machines. Attributes: USE_BACKGROUND_WORKER (bool): If True, the `print_label()` and `print_labels()` methods will be run in a background worker (default: True) @@ -30,7 +30,7 @@ class BaseLabelPrintingDriver(BaseDriver): def print_label( self, - machine: 'LabelPrintingMachineType', + machine: 'LabelPrinterMachine', label: LabelTemplate, item: LabelItemType, request: Request, @@ -54,7 +54,7 @@ def print_label( def print_labels( self, - machine: 'LabelPrintingMachineType', + machine: 'LabelPrinterMachine', label: LabelTemplate, items: QuerySet[LabelItemType], request: Request, @@ -84,7 +84,7 @@ def print_labels( def get_printers( self, label: LabelTemplate, items: QuerySet[LabelItemType], **kwargs - ) -> list['LabelPrintingMachineType']: + ) -> list['LabelPrinterMachine']: """Get all printers that would be available to print this job. By default all printers that are initialized using this driver are returned. @@ -96,11 +96,11 @@ def get_printers( Keyword Arguments: request (Request): The django request used to make the get printers request """ - return cast(list['LabelPrintingMachineType'], self.get_machines()) + return cast(list['LabelPrinterMachine'], self.get_machines()) def get_printing_options_serializer( self, request: Request, *args, **kwargs - ) -> 'BaseLabelPrintingDriver.PrintingOptionsSerializer': + ) -> 'LabelPrinterBaseDriver.PrintingOptionsSerializer': """Return a serializer class instance with dynamic printing options. Arguments: @@ -196,10 +196,10 @@ class PrintingOptionsSerializer(serializers.Serializer): Example: This example shows how to extend the default serializer and add a new option: ```py - class MyDriver(BaseLabelPrintingDriver): + class MyDriver(LabelPrinterBaseDriver): # ... - class PrintingOptionsSerializer(BaseLabelPrintingDriver.PrintingOptionsSerializer): + class PrintingOptionsSerializer(LabelPrinterBaseDriver.PrintingOptionsSerializer): auto_cut = serializers.BooleanField( default=True, label=_('Auto cut'), @@ -233,14 +233,14 @@ class LabelPrinterStatus(MachineStatus): DISCONNECTED = 400, _('Disconnected'), 'danger' -class LabelPrintingMachineType(BaseMachineType): +class LabelPrinterMachine(BaseMachineType): """Label printer machine type, is a direct integration to print labels for various items.""" SLUG = 'label-printer' NAME = _('Label Printer') DESCRIPTION = _('Directly print labels for various items.') - base_driver = BaseLabelPrintingDriver + base_driver = LabelPrinterBaseDriver MACHINE_SETTINGS = { 'LOCATION': { diff --git a/InvenTree/machine/test_api.py b/InvenTree/machine/test_api.py index c87f43c9c6f..24deed5e833 100644 --- a/InvenTree/machine/test_api.py +++ b/InvenTree/machine/test_api.py @@ -8,7 +8,7 @@ from InvenTree.unit_test import InvenTreeAPITestCase from machine import registry from machine.machine_type import BaseDriver, BaseMachineType -from machine.machine_types import BaseLabelPrintingDriver +from machine.machine_types import LabelPrinterBaseDriver from machine.models import MachineConfig from machine.tests import TestMachineRegistryMixin from stock.models import StockLocation @@ -24,7 +24,7 @@ class MachineAPITest(TestMachineRegistryMixin, InvenTreeAPITestCase): def setUp(self): """Setup some testing drivers/machines.""" - class TestingLabelPrinterDriver(BaseLabelPrintingDriver): + class TestingLabelPrinterDriver(LabelPrinterBaseDriver): """Test driver for label printing.""" SLUG = 'test-label-printer-api' @@ -45,7 +45,7 @@ def restart_machine(self, machine: BaseMachineType): def print_label(self, *args, **kwargs) -> None: """Override print_label.""" - class TestingLabelPrinterDriverError1(BaseLabelPrintingDriver): + class TestingLabelPrinterDriverError1(LabelPrinterBaseDriver): """Test driver for label printing.""" SLUG = 'test-label-printer-error' @@ -55,7 +55,7 @@ class TestingLabelPrinterDriverError1(BaseLabelPrintingDriver): def print_label(self, *args, **kwargs) -> None: """Override print_label.""" - class TestingLabelPrinterDriverError2(BaseLabelPrintingDriver): + class TestingLabelPrinterDriverError2(LabelPrinterBaseDriver): """Test driver for label printing.""" SLUG = 'test-label-printer-error' @@ -65,7 +65,7 @@ class TestingLabelPrinterDriverError2(BaseLabelPrintingDriver): def print_label(self, *args, **kwargs) -> None: """Override print_label.""" - class TestingLabelPrinterDriverNotImplemented(BaseLabelPrintingDriver): + class TestingLabelPrinterDriverNotImplemented(LabelPrinterBaseDriver): """Test driver for label printing.""" SLUG = 'test-label-printer-not-implemented' @@ -94,7 +94,7 @@ def test_machine_type_list(self): ) self.assertTrue( machine_type['provider_file'].endswith( - 'machine/machine_types/LabelPrintingMachineType.py' + 'machine/machine_types/label_printer.py' ) ) diff --git a/InvenTree/plugin/builtin/labels/inventree_machine.py b/InvenTree/plugin/builtin/labels/inventree_machine.py index 826564dad9f..c2794aac978 100644 --- a/InvenTree/plugin/builtin/labels/inventree_machine.py +++ b/InvenTree/plugin/builtin/labels/inventree_machine.py @@ -11,7 +11,7 @@ from InvenTree.serializers import DependentField from InvenTree.tasks import offload_task from label.models import LabelTemplate -from machine.machine_types import BaseLabelPrintingDriver, LabelPrintingMachineType +from machine.machine_types import LabelPrinterBaseDriver, LabelPrinterMachine from plugin import InvenTreePlugin from plugin.machine import registry from plugin.mixins import LabelPrintingMixin @@ -27,13 +27,13 @@ def get_machine_and_driver(machine_pk: str): if machine.SLUG != 'label_printer': return None, None - machine = cast(LabelPrintingMachineType, machine) + machine = cast(LabelPrinterMachine, machine) driver = machine.driver if driver is None: return machine, None - return machine, cast(BaseLabelPrintingDriver, driver) + return machine, cast(LabelPrinterBaseDriver, driver) def get_last_used_printers(user): @@ -114,9 +114,9 @@ def __init__(self, *args, **kwargs): items_to_print = view.get_items() # get all available printers for each driver - machines: list[LabelPrintingMachineType] = [] + machines: list[LabelPrinterMachine] = [] for driver in cast( - list[BaseLabelPrintingDriver], registry.get_drivers('label_printer') + list[LabelPrinterBaseDriver], registry.get_drivers('label_printer') ): machines.extend( driver.get_printers( @@ -150,7 +150,7 @@ def __init__(self, *args, **kwargs): self.fields['machine'].choices = choices - def get_printer_name(self, machine: LabelPrintingMachineType): + def get_printer_name(self, machine: LabelPrinterMachine): """Construct the printers name.""" name = machine.name diff --git a/docs/docs/extend/machines/label_printer.md b/docs/docs/extend/machines/label_printer.md index 00e3bef00f0..060bd3ec0de 100644 --- a/docs/docs/extend/machines/label_printer.md +++ b/docs/docs/extend/machines/label_printer.md @@ -4,13 +4,13 @@ Label printer machines can directly print labels for various items in InvenTree. ### Writing your own printing driver -Take a look at the most basic required code for a driver in this [example](./overview.md#example-driver). Next either implement the [`print_label`](#machine.machine_types.BaseLabelPrintingDriver.print_label) or [`print_labels`](#machine.machine_types.BaseLabelPrintingDriver.print_labels) function. +Take a look at the most basic required code for a driver in this [example](./overview.md#example-driver). Next either implement the [`print_label`](#machine.machine_types.LabelPrinterBaseDriver.print_label) or [`print_labels`](#machine.machine_types.LabelPrinterBaseDriver.print_labels) function. ### Label printer status There are a couple of predefined status codes for label printers. By default the `UNKNOWN` status code is set for each machine, but they can be changed at any time by the driver. For more info about status code see [Machine status codes](./overview.md#machine-status). -::: machine.machine_types.LabelPrintingMachineType.LabelPrinterStatus +::: machine.machine_types.label_printer.LabelPrinterStatus options: heading_level: 4 show_bases: false @@ -18,7 +18,7 @@ There are a couple of predefined status codes for label printers. By default the ### LabelPrintingDriver API -::: machine.machine_types.BaseLabelPrintingDriver +::: machine.machine_types.LabelPrinterBaseDriver options: heading_level: 4 show_bases: false diff --git a/docs/docs/extend/machines/overview.md b/docs/docs/extend/machines/overview.md index 4e1c29fabff..7ceb8e146cf 100644 --- a/docs/docs/extend/machines/overview.md +++ b/docs/docs/extend/machines/overview.md @@ -39,7 +39,7 @@ If you want to create your own machine type, please also take a look at the alre from django.utils.translation import ugettext_lazy as _ from plugin.machine import BaseDriver, BaseMachineType, MachineStatus -class BaseABCDriver(BaseDriver): +class ABCBaseDriver(BaseDriver): """Base xyz driver.""" machine_type = 'abc' @@ -54,12 +54,12 @@ class BaseABCDriver(BaseDriver): required_overrides = [my_custom_required_method] -class ABCMachineType(BaseMachineType): +class ABCMachine(BaseMachineType): SLUG = 'abc' NAME = _('ABC') DESCRIPTION = _('This is an awesome machine type for ABC.') - base_driver = BaseABCDriver + base_driver = ABCBaseDriver class ABCStatus(MachineStatus): CONNECTED = 100, _('Connected'), 'success' @@ -67,7 +67,6 @@ class ABCMachineType(BaseMachineType): PRINTING = 110, _('Printing'), 'primary' MACHINE_STATUS = ABCStatus - default_machine_status = ABCStatus.DISCONNECTED ``` @@ -103,18 +102,18 @@ A basic driver only needs to specify the basic attributes like `SLUG`, `NAME`, ` ```py from plugin import InvenTreePlugin -from plugin.machine.machine_types import BaseXYZDriver +from plugin.machine.machine_types import ABCBaseDriver -class MyABCXYZDriverPlugin(InvenTreePlugin): - NAME = "ABCXYZDriver" - SLUG = "abc-driver" - TITLE = "ABC XYZ Driver" +class MyXyzAbcDriverPlugin(InvenTreePlugin): + NAME = "XyzAbcDriver" + SLUG = "xyz-driver" + TITLE = "Xyz Abc Driver" # ... -class MyXYZDriver(BaseXYZDriver): - SLUG = 'my-abc-driver' - NAME = 'My ABC driver' - DESCRIPTION = 'This is an awesome driver for ABC' +class XYZDriver(ABCBaseDriver): + SLUG = 'my-xyz-driver' + NAME = 'My XYZ driver' + DESCRIPTION = 'This is an awesome XYZ driver for a ABC machine' ``` #### Driver API @@ -136,7 +135,7 @@ class MyXYZDriver(BaseXYZDriver): Each machine can have different settings configured. There are machine settings that are specific to that machine type and driver settings that are specific to the driver, but both can be specified individually for each machine. Define them by adding a `MACHINE_SETTINGS` dictionary attribute to either the driver or the machine type. The format follows the same pattern as the `SETTINGS` for normal plugins documented on the [`SettingsMixin`](../plugins/settings.md) ```py -class MyXYZDriver(BaseXYZDriver): +class MyXYZDriver(ABCBaseDriver): MACHINE_SETTINGS = { 'SERVER': { 'name': _('Server'), @@ -175,7 +174,7 @@ class XYZMachineType(BaseMachineType): And to set a status code for a machine by the driver. ```py -class MyXYZDriver(BaseXYZDriver): +class MyXYZDriver(ABCBaseDriver): # ... def init_machine(self, machine): # ... do some init stuff here @@ -194,7 +193,7 @@ class MyXYZDriver(BaseXYZDriver): There can also be a free text status code defined. ```py -class MyXYZDriver(BaseXYZDriver): +class MyXYZDriver(ABCBaseDriver): # ... def init_machine(self, machine): # ... do some init stuff here From 502863fbabd2fa8c8c9648a9bf400a01636f6817 Mon Sep 17 00:00:00 2001 From: wolflu05 <76838159+wolflu05@users.noreply.github.com> Date: Wed, 7 Feb 2024 14:09:43 +0000 Subject: [PATCH 82/86] Added more tests --- InvenTree/machine/test_api.py | 2 - InvenTree/machine/tests.py | 119 ++++++++++++++++++ .../builtin/labels/inventree_machine.py | 10 +- docs/docs/extend/machines/overview.md | 2 +- 4 files changed, 126 insertions(+), 7 deletions(-) diff --git a/InvenTree/machine/test_api.py b/InvenTree/machine/test_api.py index 24deed5e833..1e603bd2c2a 100644 --- a/InvenTree/machine/test_api.py +++ b/InvenTree/machine/test_api.py @@ -19,8 +19,6 @@ class MachineAPITest(TestMachineRegistryMixin, InvenTreeAPITestCase): roles = ['admin.add', 'admin.view', 'admin.change', 'admin.delete'] - placeholder_uuid = '00000000-0000-0000-0000-000000000000' - def setUp(self): """Setup some testing drivers/machines.""" diff --git a/InvenTree/machine/tests.py b/InvenTree/machine/tests.py index 51e9b377ed5..5bbc04fb5ac 100755 --- a/InvenTree/machine/tests.py +++ b/InvenTree/machine/tests.py @@ -1,17 +1,30 @@ """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 InvenTreeAPITestCase +from label.models import PartLabel 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 +from plugin.models import PluginConfig +from plugin.registry import registry as plg_registry class TestMachineRegistryMixin(TestCase): """Machine registry test mixin to setup the registry between tests correctly.""" + placeholder_uuid = '00000000-0000-0000-0000-000000000000' + def tearDown(self) -> None: """Clean up after testing.""" registry.machine_types = {} @@ -181,3 +194,109 @@ def test_machine_lifecycle(self): self.assertEqual(len(registry.get_machines()), 2) registry.remove_machine(machine1) self.assertEqual(len(registry.get_machines()), 1) + + +class TestLabelPrinterMachineType(TestMachineRegistryMixin, InvenTreeAPITestCase): + """Test the label printer machine type.""" + + fixtures = ['category', 'part', 'location', 'stock'] + + def setUp(self): + """Setup the label printer machine type.""" + super().setUp() + + class TestingLabelPrinterDriver(LabelPrinterBaseDriver): + """Label printer driver for testing.""" + + SLUG = 'testing-label-printer' + NAME = 'Testing Label Printer' + DESCRIPTION = 'This is a test label printer driver for testing.' + + class PrintingOptionsSerializer( + LabelPrinterBaseDriver.PrintingOptionsSerializer + ): + """Test printing options serializer.""" + + test_option = serializers.IntegerField() + + def print_label(self, *args, **kwargs): + """Mock print label method so that there are no errors.""" + + self.machine = MachineConfig.objects.create( + name='Test Label Printer', + machine_type='label-printer', + driver='testing-label-printer', + active=True, + ) + + registry.initialize() + driver_instance = cast( + TestingLabelPrinterDriver, + registry.get_driver_instance('testing-label-printer'), + ) + + self.print_label = Mock() + driver_instance.print_label = self.print_label + + self.print_labels = Mock(side_effect=driver_instance.print_labels) + driver_instance.print_labels = self.print_labels + + def test_print_label(self): + """Test the print label method.""" + plugin_ref = 'inventreelabelmachine' + + # setup the label app + apps.get_app_config('label').create_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() + + parts = Part.objects.all()[:2] + label = cast(PartLabel, PartLabel.objects.first()) + + url = reverse('api-part-label-print', kwargs={'pk': label.pk}) + url += f'/?plugin={plugin_ref}&part[]={parts[0].pk}&part[]={parts[1].pk}' + + self.post( + url, + { + 'machine': str(self.machine.pk), + 'driver_options': {'copies': '1', 'test_option': '2'}, + }, + expected_code=200, + ) + + # 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], label) + self.assertQuerySetEqual( + self.print_labels.call_args.args[2], parts, transform=lambda x: x + ) + 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 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], label) + 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, + ) diff --git a/InvenTree/plugin/builtin/labels/inventree_machine.py b/InvenTree/plugin/builtin/labels/inventree_machine.py index c2794aac978..86d6b361100 100644 --- a/InvenTree/plugin/builtin/labels/inventree_machine.py +++ b/InvenTree/plugin/builtin/labels/inventree_machine.py @@ -21,10 +21,11 @@ def get_machine_and_driver(machine_pk: str): """Get the driver by machine pk and ensure that it is a label printing driver.""" machine = registry.get_machine(machine_pk) - if machine is None: + # machine should be valid due to the machine select field validator + if machine is None: # pragma: no cover return None, None - if machine.SLUG != 'label_printer': + if machine.SLUG != 'label-printer': # pragma: no cover return None, None machine = cast(LabelPrinterMachine, machine) @@ -68,7 +69,8 @@ def print_labels(self, label: LabelTemplate, items, request, **kwargs): kwargs['printing_options'].get('machine', '') ) - if driver is None or machine is None: + # the driver and machine should be valid due to the machine select field validator + if driver is None or machine is None: # pragma: no cover return None print_kwargs = { @@ -116,7 +118,7 @@ def __init__(self, *args, **kwargs): # get all available printers for each driver machines: list[LabelPrinterMachine] = [] for driver in cast( - list[LabelPrinterBaseDriver], registry.get_drivers('label_printer') + list[LabelPrinterBaseDriver], registry.get_drivers('label-printer') ): machines.extend( driver.get_printers( diff --git a/docs/docs/extend/machines/overview.md b/docs/docs/extend/machines/overview.md index 7ceb8e146cf..e3c822425c6 100644 --- a/docs/docs/extend/machines/overview.md +++ b/docs/docs/extend/machines/overview.md @@ -36,7 +36,7 @@ Each machine type can provide a different type of connection functionality betwe If you want to create your own machine type, please also take a look at the already existing machine types in `machines/machine_types/*.py`. The following example creates a machine type called `abc`. ```py -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ from plugin.machine import BaseDriver, BaseMachineType, MachineStatus class ABCBaseDriver(BaseDriver): From 8f7b2478198d8955661104057ed1d0688d61594b Mon Sep 17 00:00:00 2001 From: wolflu05 <76838159+wolflu05@users.noreply.github.com> Date: Wed, 7 Feb 2024 14:39:09 +0000 Subject: [PATCH 83/86] Fix tests --- InvenTree/plugin/builtin/labels/inventree_machine.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/InvenTree/plugin/builtin/labels/inventree_machine.py b/InvenTree/plugin/builtin/labels/inventree_machine.py index 86d6b361100..13c91a35a9f 100644 --- a/InvenTree/plugin/builtin/labels/inventree_machine.py +++ b/InvenTree/plugin/builtin/labels/inventree_machine.py @@ -22,17 +22,17 @@ def get_machine_and_driver(machine_pk: str): machine = registry.get_machine(machine_pk) # machine should be valid due to the machine select field validator - if machine is None: # pragma: no cover - return None, None + if machine is None: + return None, None # pragma: no cover - if machine.SLUG != 'label-printer': # pragma: no cover - return None, None + if machine.SLUG != 'label-printer': + return None, None # pragma: no cover machine = cast(LabelPrinterMachine, machine) driver = machine.driver if driver is None: - return machine, None + return machine, None # pragma: no cover return machine, cast(LabelPrinterBaseDriver, driver) From 6571feae1fa982e445622934d722d518118221b3 Mon Sep 17 00:00:00 2001 From: wolflu05 <76838159+wolflu05@users.noreply.github.com> Date: Wed, 7 Feb 2024 21:49:54 +0000 Subject: [PATCH 84/86] Added setting choice with kwargs and get_machines with initialized=None --- InvenTree/common/models.py | 6 +++++- InvenTree/machine/machine_type.py | 2 +- InvenTree/machine/registry.py | 7 +++++-- InvenTree/machine/tests.py | 1 + 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py index a8cb9e5beda..9a13fda7e23 100644 --- a/InvenTree/common/models.py +++ b/InvenTree/common/models.py @@ -525,7 +525,11 @@ def get_setting_choices(cls, key, **kwargs): if callable(choices): # Evaluate the function (we expect it will return a list of tuples...) - return choices() + try: + # Attempt to pass the kwargs to the function, if it doesn't expect them, ignore and call without + return choices(**kwargs) + except TypeError: + return choices() return choices diff --git a/InvenTree/machine/machine_type.py b/InvenTree/machine/machine_type.py index f928d65735e..d92d0540d4e 100644 --- a/InvenTree/machine/machine_type.py +++ b/InvenTree/machine/machine_type.py @@ -117,7 +117,7 @@ def get_machines(self, **kwargs): Keyword Arguments: name (str): Machine name machine_type (BaseMachineType): Machine type definition (class) - initialized (bool): default: True + initialized (bool | None): use None to get all machines (default: True) active (bool): machine needs to be active base_driver (BaseDriver): base driver (class) """ diff --git a/InvenTree/machine/registry.py b/InvenTree/machine/registry.py index 3caa5e3178c..fbd712fef28 100644 --- a/InvenTree/machine/registry.py +++ b/InvenTree/machine/registry.py @@ -165,7 +165,7 @@ def get_machines(self, **kwargs): name: Machine name machine_type: Machine type definition (class) driver: Machine driver (class) - initialized: (bool, default: True) + initialized (bool | None): use None to get all machines (default: True) active: (bool) base_driver: base driver (class) """ @@ -178,7 +178,10 @@ def get_machines(self, **kwargs): 'base_driver', ] - kwargs = {'initialized': True, **kwargs} + if 'initialized' not in kwargs: + kwargs['initialized'] = True + if kwargs['initialized'] is None: + del kwargs['initialized'] def filter_machine(machine: BaseMachineType): for key, value in kwargs.items(): diff --git a/InvenTree/machine/tests.py b/InvenTree/machine/tests.py index 5bbc04fb5ac..03cbb746526 100755 --- a/InvenTree/machine/tests.py +++ b/InvenTree/machine/tests.py @@ -132,6 +132,7 @@ def test_machine_lifecycle(self): # 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=False, initialized=False)), 1) self.assertEqual(len(registry.get_machines(name='Test Machine 1')), 1) self.assertEqual( From ec6b9728fc581f8b1982c50b62f5b25e2188eeb1 Mon Sep 17 00:00:00 2001 From: wolflu05 <76838159+wolflu05@users.noreply.github.com> Date: Wed, 7 Feb 2024 21:56:30 +0000 Subject: [PATCH 85/86] Refetch table after deleting machine --- src/frontend/src/tables/machine/MachineListTable.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/frontend/src/tables/machine/MachineListTable.tsx b/src/frontend/src/tables/machine/MachineListTable.tsx index 813d7842553..e248c62d90d 100644 --- a/src/frontend/src/tables/machine/MachineListTable.tsx +++ b/src/frontend/src/tables/machine/MachineListTable.tsx @@ -249,7 +249,10 @@ function MachineDrawer({ preFormContent: ( {t`Are you sure you want to remove the machine "${machine?.name}"?`} ), - onFormSuccess: () => navigate(-1) + onFormSuccess: () => { + refreshTable(); + navigate(-1); + } }); } }), From e6f88a0890a8bdabf9d2108acd2ee8bfd57e5bd1 Mon Sep 17 00:00:00 2001 From: wolflu05 <76838159+wolflu05@users.noreply.github.com> Date: Wed, 7 Feb 2024 22:22:12 +0000 Subject: [PATCH 86/86] Fix test --- InvenTree/plugin/builtin/labels/inventree_machine.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/InvenTree/plugin/builtin/labels/inventree_machine.py b/InvenTree/plugin/builtin/labels/inventree_machine.py index 13c91a35a9f..1d5d8c6651b 100644 --- a/InvenTree/plugin/builtin/labels/inventree_machine.py +++ b/InvenTree/plugin/builtin/labels/inventree_machine.py @@ -22,17 +22,17 @@ def get_machine_and_driver(machine_pk: str): machine = registry.get_machine(machine_pk) # machine should be valid due to the machine select field validator - if machine is None: - return None, None # pragma: no cover + if machine is None: # pragma: no cover + return None, None - if machine.SLUG != 'label-printer': - return None, None # pragma: no cover + if machine.SLUG != 'label-printer': # pragma: no cover + return None, None machine = cast(LabelPrinterMachine, machine) driver = machine.driver - if driver is None: - return machine, None # pragma: no cover + if driver is None: # pragma: no cover + return machine, None return machine, cast(LabelPrinterBaseDriver, driver)