Machine integration (#4824)

* Added initial draft for machines

* refactor: isPluginRegistryLoaded check into own ready function

* Added suggestions from codereview

* Refactor: base_drivers -> machine_types

* Use new BaseInvenTreeSetting unique interface

* Fix Django not ready error

* Added get_machines function to driver

- get_machines function on driver
- get_machine function on driver
- initialized attribute on machine

* Added error handeling for driver and machine type

* Extended get_machines functionality

* Export everything from plugin module

* Fix spelling mistakes

* Better states handeling, BaseMachineType is now used instead of Machine Model

* Use uuid as pk

* WIP: machine termination hook

* Remove termination hook as this does not work with gunicorn

* Remove machine from registry after delete

* Added ClassProviderMixin

* Check for slug dupplication

* Added config_type to MachineSettings to define machine/driver settings

* Refactor helper mixins into own file in InvenTree app

* Fixed typing and added required_attributes for BaseDriver

* fix: generic status import

* Added first draft for machine states

* Added convention for status codes

* Added update_machine hook

* Removed unnecessary _key suffix from machine config model

* Initil draft for machine API

* Refactored BaseInvenTreeSetting all_items and allValues method

* Added required to InvenTreeBaseSetting and check_settings method

* check if all required machine settings are defined and refactor: use getattr

* Fix: comment

* Fix initialize error and python 3.9 compability

* Make machine states available through the global states api

* Added basic PUI machine admin implementation that is still in dev

* Added basic machine setting UI to PUI

* Added machine detail view to PUI admin center

* Fix merge issues

* Fix style issues

* Added machine type,machine driver,error stack tables

* Fix style in machine/serializers.py

* Added pui link from machine to machine type/driver drawer

* Removed only partially working django admin in favor of the PUI admin center implementation

* Added required field to settings item

* Added machine restart function

* Added restart requird badge to machine table/drawer

* Added driver init function

* handle error functions for machines and registry

* Added driver errors

* Added machine table to driver drawer

* Added back button to detail drawer component

* Fix auto formatable pre-commit

* fix: style

* Fix deepsource

* Removed slug field from table, added more links between drawers, remove detail drawer blur

* Added initial docs

* Removed description from driver/machine type select and fixed disabled driver select if no machine type is selected

* Added basic label printing implementation

* Remove translated column names because they are now retrieved from the api

* Added printer location setting

* Save last 10 used printer machine per user and sort them in the printing dialog

* Added BasePrintingOptionsSerializer for common options

* Fix not printing_options are not properly casted to its internal value

* Fix type

* Improved machine docs

* Fix docs

* Added UNKNOWN status code to label printer status

* Skip machine loading when running migrations

* Fix testing?

* Fix: tests?

* Fix: tests?

* Disable docs check precommit

* Disable docs check precommit

* First draft for tests

* fix test

* Add type ignore

* Added API tests

* Test ci?

* Add more tests

* Added more tests

* Bump api version

* Changed driver/base driver naming schema

* Added more tests

* Fix tests

* Added setting choice with kwargs and get_machines with initialized=None

* Refetch table after deleting machine

* Fix test

---------

Co-authored-by: Matthias Mair <code@mjmair.com>
This commit is contained in:
Lukas 2024-02-14 15:13:47 +01:00 committed by GitHub
parent aed7754bc2
commit aa7eaaab3a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
50 changed files with 4243 additions and 61 deletions

View File

@ -1,11 +1,20 @@
"""InvenTree API version information."""
# InvenTree API version
INVENTREE_API_VERSION = 167
INVENTREE_API_VERSION = 168
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
v168 -> 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
v167 -> 2024-02-07: https://github.com/inventree/InvenTree/pull/6440
- Fixes for OpenAPI schema generation

View File

@ -8,6 +8,7 @@ import os
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
@ -885,7 +886,10 @@ def get_objectreference(
return {'name': str(item), 'model': str(model_cls._meta.verbose_name), **ret}
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]

View File

@ -0,0 +1,106 @@
"""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, a nested list mean either one of them needs an 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 = []
required_overrides = []
@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."""
if isinstance(base_implementation, list):
return all(override_missing(x) for x in 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:
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(missing_overrides_list)}"
)
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

View File

@ -7,6 +7,7 @@ from rest_framework.fields import empty
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
@ -208,7 +209,10 @@ class InvenTreeMetadata(SimpleMetadata):
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):

View File

@ -158,8 +158,15 @@ class DependentField(serializers.Field):
# 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:

View File

@ -243,6 +243,7 @@ INSTALLED_APPS = [
'report.apps.ReportConfig',
'stock.apps.StockConfig',
'users.apps.UsersConfig',
'machine.apps.MachineConfig',
'web',
'generic',
'InvenTree.apps.InvenTreeConfig', # InvenTree app runs last

View File

@ -29,6 +29,7 @@ import InvenTree.helpers_model
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
@ -1317,3 +1318,98 @@ class MaintenanceModeTest(InvenTreeTestCase):
set_maintenance_mode(False)
self.assertFalse(get_maintenance_mode())
self.assertEqual(InvenTreeSetting.get_setting(KEY, None), '')
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())

View File

@ -22,6 +22,7 @@ import build.api
import common.api
import company.api
import label.api
import machine.api
import order.api
import part.api
import plugin.api
@ -83,6 +84,7 @@ apipatterns = [
path('order/', include(order.api.order_api_urls)),
path('label/', include(label.api.label_api_urls)),
path('report/', include(report.api.report_api_urls)),
path('machine/', include(machine.api.machine_api_urls)),
path('user/', include(users.api.user_urls)),
path('admin/', include(common.api.admin_api_urls)),
path('web/', include(web_api_urls)),

View File

@ -525,7 +525,11 @@ class BaseInvenTreeSetting(models.Model):
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
@ -2359,6 +2363,11 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
'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'

View File

@ -59,6 +59,8 @@ class SettingsSerializer(InvenTreeModelSerializer):
units = serializers.CharField(read_only=True)
required = serializers.BooleanField(read_only=True)
typ = serializers.CharField(read_only=True)
def get_choices(self, obj):
@ -150,6 +152,7 @@ class GenericReferencedSettingSerializer(SettingsSerializer):
'model_name',
'api_url',
'typ',
'required',
]
# set Meta class

View File

@ -75,10 +75,16 @@ class AllStatusViews(StatusView):
"""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=None):
"""Recursively discover status classes."""
for status_class in parent_status_class.__subclasses__():
name = '__'.join([*(prefix or []), 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)

View File

@ -236,7 +236,10 @@ class LabelPrintMixin(LabelFilterMixin):
try:
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 {}),
)
except ValidationError as e:
raise (e)

4
InvenTree/machine/__init__.py Executable file
View File

@ -0,0 +1,4 @@
from machine.machine_type import BaseDriver, BaseMachineType, MachineStatus
from machine.registry import registry
__all__ = ['registry', 'BaseMachineType', 'BaseDriver', 'MachineStatus']

48
InvenTree/machine/admin.py Executable file
View File

@ -0,0 +1,48 @@
"""Django admin interface for the machine app."""
from django.contrib import admin
from django.http.request import HttpRequest
from machine import models
class MachineSettingInline(admin.TabularInline):
"""Inline admin class for MachineSetting."""
model = models.MachineSetting
read_only_fields = ['key', 'config_type']
def has_add_permission(self, request, obj):
"""The machine settings should not be meddled with manually."""
return False
@admin.register(models.MachineConfig)
class MachineConfigAdmin(admin.ModelAdmin):
"""Custom admin with restricted id fields."""
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 self.readonly_fields

251
InvenTree/machine/api.py Normal file
View File

@ -0,0 +1,251 @@
"""JSON API for the machine app."""
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 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 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):
"""Return all settings for a machine config."""
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,
)
all_settings.extend(list(settings_dict.values()))
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)
"""
lookup_field = 'key'
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 = {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()}'"
)
return MachineSetting.get_setting_object(
key, machine_config=machine.machine_config, config_type=config_type
)
class MachineRestart(APIView):
"""Endpoint for performing a machine restart.
- 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)
result = MachineSerializers.MachineRestartSerializer({'ok': True}).data
return Response(result)
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):
"""List all machine types."""
machine_types = list(registry.machine_types.values())
results = MachineSerializers.MachineTypeSerializer(
machine_types, many=True
).data
return Response(results)
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):
"""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)
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
@extend_schema(
responses={200: MachineSerializers.MachineRegistryStatusSerializer()}
)
def get(self, request):
"""Provide status data for the machine registry."""
result = MachineSerializers.MachineRegistryStatusSerializer({
'registry_errors': [{'message': str(error)} for error in registry.errors]
}).data
return Response(result)
machine_api_urls = [
# machine types
path('types/', MachineTypesList.as_view(), name='api-machine-types'),
# machine drivers
path('drivers/', MachineDriverList.as_view(), name='api-machine-drivers'),
# registry status
path('status/', RegistryStatusView.as_view(), name='api-machine-registry-status'),
# detail views for a single Machine
path(
'<uuid:pk>/',
include([
# settings
path(
'settings/',
include([
re_path(
r'^(?P<config_type>M|D)/(?P<key>\w+)/',
MachineSettingDetail.as_view(),
name='api-machine-settings-detail',
),
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'),
]),
),
# machine list and create
path('', MachineList.as_view(), name='api-machine-list'),
]

43
InvenTree/machine/apps.py Executable file
View File

@ -0,0 +1,43 @@
"""Django machine app config."""
import logging
from django.apps import AppConfig
from django.db.utils import OperationalError, ProgrammingError
from InvenTree.ready import (
canAppAccessDatabase,
isImportingData,
isInMainThread,
isPluginRegistryLoaded,
isRunningMigrations,
)
logger = logging.getLogger('inventree')
class MachineConfig(AppConfig):
"""AppConfig class for the machine app."""
name = 'machine'
def ready(self) -> None:
"""Initialization method for the machine app."""
if (
not canAppAccessDatabase(allow_test=True)
or not isPluginRegistryLoaded()
or not isInMainThread()
or isRunningMigrations()
or isImportingData()
):
logger.debug('Machine app: Skipping machine loading sequence')
return
from machine import registry
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')

View File

@ -0,0 +1,375 @@
"""Base machine type/base driver."""
from typing import TYPE_CHECKING, Any, Dict, List, Literal, Tuple, Type, Union
from generic.states import StatusCode
from InvenTree.helpers_mixin import ClassProviderMixin, ClassValidationMixin
# Import only for typechecking, otherwise this throws cyclic import errors
if TYPE_CHECKING:
from common.models import SettingsKeyType
from machine.models import MachineConfig
else: # pragma: no cover
class MachineConfig:
"""Only used if not typechecking currently."""
class SettingsKeyType:
"""Only used if not typechecking currently."""
class MachineStatus(StatusCode):
"""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`.
Available colors:
primary, secondary, warning, danger, success, warning, info
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
```
"""
class BaseDriver(ClassValidationMixin, ClassProviderMixin):
"""Base class for all machine drivers.
Attributes:
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
"""
SLUG: str
NAME: str
DESCRIPTION: str
MACHINE_SETTINGS: Dict[str, SettingsKeyType]
machine_type: str
required_attributes = ['SLUG', 'NAME', 'DESCRIPTION', 'machine_type']
def __init__(self) -> None:
"""Base driver __init__ method."""
super().__init__()
self.errors: list[Union[str, Exception]] = []
def init_driver(self):
"""This method gets called after all machines are created and can be used to initialize the driver.
After the driver is initialized, the self.init_machine function is
called for each machine associated with that driver.
"""
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
list and the machine does not initialize successfully.
Arguments:
machine: Machine instance
"""
def update_machine(
self, old_machine_state: Dict[str, Any], machine: 'BaseMachineType'
):
"""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
Arguments:
old_machine_state: Dict holding the old machine state before update
machine: Machine instance with the new state
"""
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 before this function is called
Arguments:
machine: Machine instance
"""
def get_machines(self, **kwargs):
"""Return all machines using this driver (By default only initialized machines).
Keyword Arguments:
name (str): Machine name
machine_type (BaseMachineType): Machine type definition (class)
initialized (bool | None): use None to get all machines (default: True)
active (bool): machine needs to be active
base_driver (BaseDriver): 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]):
"""Handle driver error.
Arguments:
error: Exception or string
"""
self.errors.append(error)
class BaseMachineType(ClassValidationMixin, ClassProviderMixin):
"""Base class for machine types.
Attributes:
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
MACHINE_SETTINGS: Machine type specific settings dict (optional)
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]
MACHINE_STATUS: Type[MachineStatus]
default_machine_status: MachineStatus
# used by the ClassValidationMixin
required_attributes = [
'SLUG',
'NAME',
'DESCRIPTION',
'base_driver',
'MACHINE_STATUS',
'default_machine_status',
]
def __init__(self, machine_config: MachineConfig) -> None:
"""Base machine type __init__ function."""
from machine import registry
from machine.models import MachineSetting
self.errors: list[Union[str, Exception]] = []
self.initialized = False
self.status = self.default_machine_status
self.status_text: str = ''
self.pk = machine_config.pk
self.driver = registry.get_driver_instance(machine_config.driver)
if not self.driver:
self.handle_error(f"Driver '{machine_config.driver}' not found")
if self.driver and not isinstance(self.driver, self.base_driver):
self.handle_error(
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
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):
"""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
return MachineConfig.objects.get(pk=self.pk)
@property
def name(self):
"""The machines name."""
return self.machine_config.name
@property
def active(self):
"""The machines active status."""
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:
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.handle_error(f"Missing {' and '.join(error_parts)}")
return
try:
self.driver.init_machine(self)
self.initialized = True
except Exception as 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.
Arguments:
old_state: Dict holding the old machine state before update
"""
if self.driver is None:
return
try:
self.driver.update_machine(old_state, self)
except Exception as e:
self.handle_error(e)
def restart(self):
"""Machine restart function, can be used to manually restart the machine from the admin ui."""
if self.driver is None:
return
try:
self.restart_required = False
self.driver.restart_machine(self)
except Exception as e:
self.handle_error(e)
# --- helper functions
def handle_error(self, error: Union[Exception, str]):
"""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: 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_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
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: 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_str: Either "M" (machine scoped settings) or "D" (driver scoped settings)
value: The 'value' of the setting
"""
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,
)
def check_settings(self):
"""Check if all required settings for this machine are defined.
Returns:
is_valid: Are all required settings defined
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: 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[config_type] = missing
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.
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.
Arguments:
status_text: The new status text to set
"""
self.status_text = status_text

View File

@ -0,0 +1,11 @@
from machine.machine_types.label_printer import (
LabelPrinterBaseDriver,
LabelPrinterMachine,
)
__all__ = [
# machine types
'LabelPrinterMachine',
# base drivers
'LabelPrinterBaseDriver',
]

View File

@ -0,0 +1,265 @@
"""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
from stock.models import StockLocation
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)
"""
machine_type = 'label-printer'
USE_BACKGROUND_WORKER = True
def print_label(
self,
machine: 'LabelPrinterMachine',
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 (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
Note that the supplied args/kwargs may be different if the driver overrides the print_labels() method.
"""
def print_labels(
self,
machine: 'LabelPrinterMachine',
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 (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
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['LabelPrinterMachine']:
"""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)
Keyword Arguments:
request (Request): The django request used to make the get printers request
"""
return cast(list['LabelPrinterMachine'], self.get_machines())
def get_printing_options_serializer(
self, request: Request, *args, **kwargs
) -> 'LabelPrinterBaseDriver.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
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
"""
return self.PrintingOptionsSerializer(*args, **kwargs) # type: ignore
# --- helper functions
@property
def machine_plugin(self) -> LabelPrintingMixin:
"""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:
"""Helper method to render a label to PDF format for a specific item.
Arguments:
label: The LabelTemplate object to render
item: The item to render the label with
request: The HTTP request object which triggered this print job
"""
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:
"""Helper method to render a label to PDF and return it as bytes for a specific item.
Arguments:
label: The LabelTemplate object to render
item: The item to render the label with
request: The HTTP request object which triggered this print job
"""
return (
self.render_to_pdf(label, item, request, **kwargs)
.get_document() # type: ignore
.write_pdf()
)
def render_to_html(
self, label: LabelTemplate, item: LabelItemType, request: Request, **kwargs
) -> str:
"""Helper method to render a label to HTML format for a specific item.
Arguments:
label: The LabelTemplate object to render
item: The item to render the label with
request: The HTTP request object which triggered this print job
"""
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:
"""Helper method to render a label to PNG format for a specific item.
Arguments:
label: The LabelTemplate object to render
item: The item to render the label with
request: The HTTP request object which triggered this print job
Keyword Arguments:
pdf_data (bytes): The pdf document as bytes (optional)
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)
label.object_to_print = None
return png
required_overrides = [[print_label, print_labels]]
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(LabelPrinterBaseDriver):
# ...
class PrintingOptionsSerializer(LabelPrinterBaseDriver.PrintingOptionsSerializer):
auto_cut = serializers.BooleanField(
default=True,
label=_('Auto cut'),
help_text=_('Automatically cut the label after printing'),
)
```
"""
copies = serializers.IntegerField(
default=1,
label=_('Copies'),
help_text=_('Number of copies to print for each label'),
)
class LabelPrinterStatus(MachineStatus):
"""Label printer status codes.
Attributes:
CONNECTED: The printer is connected and ready to print
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, _('Unknown'), 'secondary'
PRINTING = 110, _('Printing'), 'primary'
NO_MEDIA = 301, _('No media'), 'warning'
DISCONNECTED = 400, _('Disconnected'), 'danger'
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 = LabelPrinterBaseDriver
MACHINE_SETTINGS = {
'LOCATION': {
'name': _('Printer Location'),
'description': _('Scope the printer to a specific location'),
'model': 'stock.stocklocation',
}
}
MACHINE_STATUS = LabelPrinterStatus
default_machine_status = LabelPrinterStatus.UNKNOWN
@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)

View File

@ -0,0 +1,39 @@
# Generated by Django 3.2.19 on 2023-05-31 20:10
from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='MachineConfig',
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', 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')),
],
),
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=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')),
],
options={
'unique_together': {('machine_config', 'config_type', 'key')},
},
),
]

View File

197
InvenTree/machine/models.py Executable file
View File

@ -0,0 +1,197 @@
"""Models for the machine app."""
import uuid
from typing import Literal
from django.contrib import admin
from django.db import models
from django.utils.html import escape, format_html_join
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
import common.models
from machine import registry
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,
verbose_name=_('Name'),
help_text=_('Name of machine'),
)
machine_type = models.CharField(
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'),
)
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 save(self, *args, **kwargs) -> None:
"""Custom save function to capture creates/updates to notify the registry."""
created = self._state.adding
old_machine = None
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)
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."""
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
for setting in MachineSetting.objects.filter(machine_config=self)
}
return machine
@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."""
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."""
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):
"""Get machine errors for django admin interface."""
return format_html_join(
mark_safe('<br>'), '{}', ((str(error),) for error in self.errors)
) or mark_safe(f"<i>{_('No errors')}</i>")
@admin.display(description=_('Machine status'))
def get_machine_status(self):
"""Get machine status for django admin interface."""
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."""
typ = 'machine_config'
extra_unique_fields = ['machine_config', 'config_type']
class Meta:
"""Meta for MachineSetting."""
unique_together = [('machine_config', 'config_type', 'key')]
class ConfigType(models.TextChoices):
"""Machine setting config type enum."""
MACHINE = 'M', _('Machine')
DRIVER = 'D', _('Driver')
machine_config = models.ForeignKey(
MachineConfig,
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
)
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)
registry.update_machine(old_machine, self.machine_config)
@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':
return cls.ConfigType.DRIVER
@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 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 driver requires
"""
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)
if config_type == cls.ConfigType.DRIVER:
kwargs['settings'] = machine_config.machine.driver_settings
elif config_type == cls.ConfigType.MACHINE:
kwargs['settings'] = machine_config.machine.machine_settings
return super().get_setting_definition(key, **kwargs)

View File

@ -0,0 +1,226 @@
"""Machine registry."""
import logging
from typing import Dict, List, Set, Type, Union
from uuid import UUID
from machine.machine_type import BaseDriver, BaseMachineType
logger = logging.getLogger('inventree')
class MachineRegistry:
"""Machine registry class."""
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] = {}
self.machines: Dict[str, BaseMachineType] = {}
self.base_drivers: List[Type[BaseDriver]] = []
self.errors: list[Union[str, Exception]] = []
def handle_error(self, error: Union[Exception, str]):
"""Helper function for capturing errors with the machine registry."""
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')
machine_types: Dict[str, Type[BaseMachineType]] = {}
base_drivers: List[Type[BaseDriver]] = []
discovered_machine_types: Set[Type[BaseMachineType]] = (
InvenTree.helpers.inheritors(BaseMachineType)
)
for machine_type in discovered_machine_types:
try:
machine_type.validate()
except NotImplementedError as error:
self.handle_error(error)
continue
if machine_type.SLUG in machine_types:
self.handle_error(
ValueError(f"Cannot re-register machine type '{machine_type.SLUG}'")
)
continue
machine_types[machine_type.SLUG] = machine_type
base_drivers.append(machine_type.base_driver)
self.machine_types = machine_types
self.base_drivers = base_drivers
logger.debug('Found %s machine types', len(self.machine_types.keys()))
def discover_drivers(self):
"""Discovers all machine drivers by inferring all classes that inherit the BaseDriver class."""
import InvenTree.helpers
logger.debug('Collecting machine drivers')
drivers: Dict[str, Type[BaseDriver]] = {}
discovered_drivers: Set[Type[BaseDriver]] = InvenTree.helpers.inheritors(
BaseDriver
)
for driver in discovered_drivers:
# skip discovered drivers that define a base driver for a machine type
if driver in self.base_drivers:
continue
try:
driver.validate()
except NotImplementedError as error:
self.handle_error(error)
continue
if driver.SLUG in drivers:
self.handle_error(
ValueError(f"Cannot re-register driver '{driver.SLUG}'")
)
continue
drivers[driver.SLUG] = driver
self.drivers = 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."""
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):
"""Load all machines defined in the database into the machine registry."""
# Imports need to be in this level to prevent early db model imports
from machine.models import MachineConfig
for machine_config in MachineConfig.objects.all():
self.add_machine(machine_config, initialize=False)
# initialize drivers
for driver in self.driver_instances.values():
driver.init_driver()
# initialize machines after all machine instances were created
for machine in self.machines.values():
if machine.active:
machine.initialize()
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)
if machine_type is None:
self.handle_error(f"Machine type '{machine_config.machine_type}' not found")
return
machine: BaseMachineType = machine_type(machine_config)
self.machines[str(machine.pk)] = machine
if initialize and machine.active:
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).
Kwargs:
name: Machine name
machine_type: Machine type definition (class)
driver: Machine driver (class)
initialized (bool | None): use None to get all machines (default: True)
active: (bool)
base_driver: base driver (class)
"""
allowed_fields = [
'name',
'machine_type',
'driver',
'initialized',
'active',
'base_driver',
]
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():
if key not in allowed_fields:
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':
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
return True
return list(filter(filter_machine, self.machines.values()))
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()

View File

@ -0,0 +1,206 @@
"""Serializers for the machine app."""
from typing import List, Union
from rest_framework import serializers
from common.serializers import GenericReferencedSettingSerializer
from InvenTree.helpers_mixin import ClassProviderMixin
from machine import registry
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_model',
'status_text',
'machine_errors',
'is_driver_available',
'restart_required',
]
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')
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__
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)
class MachineConfigCreateSerializer(MachineConfigSerializer):
"""Serializer for creating a MachineConfig."""
class Meta(MachineConfigSerializer.Meta):
"""Meta for serializer."""
read_only_fields = list(
set(MachineConfigSerializer.Meta.read_only_fields)
- {'machine_type', 'driver'}
)
class MachineSettingSerializer(GenericReferencedSettingSerializer):
"""Serializer for the MachineSetting model."""
MODEL = MachineSetting
EXTRA_FIELDS = ['config_type']
def __init__(self, *args, **kwargs):
"""Custom init method to make the config_type field read only."""
super().__init__(*args, **kwargs)
self.Meta.read_only_fields = ['config_type'] # type: ignore
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:
"""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 {
'slug': plugin.slug,
'name': plugin.human_name,
'pk': getattr(plugin.plugin_config(), 'pk', None),
}
return None
def get_is_builtin(self, obj: ClassProviderMixin) -> bool:
"""Serializer method for the is_builtin field."""
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', 'errors']
machine_type = serializers.SlugField(read_only=True)
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 []
return [str(err) for err in driver_instance.errors]
class MachineRegistryErrorSerializer(serializers.Serializer):
"""Serializer for a machine registry error."""
class Meta:
"""Meta for a serializer."""
fields = ['message']
message = serializers.CharField()
class MachineRegistryStatusSerializer(serializers.Serializer):
"""Serializer for machine registry status."""
class Meta:
"""Meta for a serializer."""
fields = ['registry_errors']
registry_errors = serializers.ListField(child=MachineRegistryErrorSerializer())
class MachineRestartSerializer(serializers.Serializer):
"""Serializer for the machine restart response."""
class Meta:
"""Meta for a serializer."""
fields = ['ok']
ok = serializers.BooleanField()

View File

@ -0,0 +1,307 @@
"""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 BaseDriver, BaseMachineType
from machine.machine_types import LabelPrinterBaseDriver
from machine.models import MachineConfig
from machine.tests import TestMachineRegistryMixin
from stock.models import StockLocation
class MachineAPITest(TestMachineRegistryMixin, InvenTreeAPITestCase):
"""Class for unit testing machine API endpoints."""
roles = ['admin.add', 'admin.view', 'admin.change', 'admin.delete']
def setUp(self):
"""Setup some testing drivers/machines."""
class TestingLabelPrinterDriver(LabelPrinterBaseDriver):
"""Test driver for label printing."""
SLUG = 'test-label-printer-api'
NAME = 'Test label printer'
DESCRIPTION = 'This is a test label printer driver for testing.'
MACHINE_SETTINGS = {
'TEST_SETTING': {
'name': 'Test setting',
'description': 'This is a test setting',
}
}
def restart_machine(self, machine: BaseMachineType):
"""Override restart_machine."""
machine.set_status_text('Restarting...')
def print_label(self, *args, **kwargs) -> None:
"""Override print_label."""
class TestingLabelPrinterDriverError1(LabelPrinterBaseDriver):
"""Test driver for label printing."""
SLUG = 'test-label-printer-error'
NAME = 'Test label printer error'
DESCRIPTION = 'This is a test label printer driver for testing.'
def print_label(self, *args, **kwargs) -> None:
"""Override print_label."""
class TestingLabelPrinterDriverError2(LabelPrinterBaseDriver):
"""Test driver for label printing."""
SLUG = 'test-label-printer-error'
NAME = 'Test label printer error'
DESCRIPTION = 'This is a test label printer driver for testing.'
def print_label(self, *args, **kwargs) -> None:
"""Override print_label."""
class TestingLabelPrinterDriverNotImplemented(LabelPrinterBaseDriver):
"""Test driver for label printing."""
SLUG = 'test-label-printer-not-implemented'
NAME = 'Test label printer error not implemented'
DESCRIPTION = 'This is a test label printer driver for testing.'
registry.initialize()
super().setUp()
def test_machine_type_list(self):
"""Test machine types list API endpoint."""
response = self.get(reverse('api-machine-types'))
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',
'name': 'Label Printer',
'description': 'Directly print labels for various items.',
'provider_plugin': None,
'is_builtin': True,
},
machine_type,
)
self.assertTrue(
machine_type['provider_file'].endswith(
'machine/machine_types/label_printer.py'
)
)
def test_machine_driver_list(self):
"""Test machine driver list API endpoint."""
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.assertDictContainsSubset(
{
'slug': 'test-label-printer-api',
'name': 'Test label printer',
'description': 'This is a test label printer driver for testing.',
'provider_plugin': None,
'is_builtin': True,
'machine_type': 'label-printer',
'driver_errors': [],
},
driver,
)
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'))
errors_msgs = [e['message'] for e in response.data['registry_errors']]
required_patterns = [
r'\'<class \'.*\.TestingLabelPrinterDriverNotImplemented\'>\' did not override the required attributes: one of print_label or print_labels',
"Cannot re-register driver 'test-label-printer-error'",
]
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."""
response = self.get(reverse('api-machine-list'))
self.assertEqual(len(response.data), 0)
MachineConfig.objects.create(
machine_type='label-printer',
driver='test-label-printer-api',
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-api',
'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."""
self.assertFalse(len(MachineConfig.objects.all()), 0)
self.get(
reverse('api-machine-detail', kwargs={'pk': self.placeholder_uuid}),
expected_code=404,
)
machine_data = {
'machine_type': 'label-printer',
'driver': 'test-label-printer-api',
'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_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'},
)
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-api',
name='Test Machine',
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...')

303
InvenTree/machine/tests.py Executable file
View File

@ -0,0 +1,303 @@
"""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 = {}
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):
"""Setup some testing drivers/machines."""
class TestingMachineBaseDriver(BaseDriver):
"""Test base driver for testing machines."""
machine_type = 'testing-type'
class TestingMachineType(BaseMachineType):
"""Test machine type for testing."""
SLUG = 'testing-type'
NAME = 'Testing machine type'
DESCRIPTION = 'This is a test machine type for testing.'
base_driver = TestingMachineBaseDriver
class TestingMachineTypeStatus(MachineStatus):
"""Test machine status."""
UNKNOWN = 100, 'Unknown', 'secondary'
MACHINE_STATUS = TestingMachineTypeStatus
default_machine_status = MACHINE_STATUS.UNKNOWN
class TestingDriver(TestingMachineBaseDriver):
"""Test driver for testing machines."""
SLUG = 'test-driver'
NAME = 'Test Driver'
DESCRIPTION = 'This is a test driver for testing.'
MACHINE_SETTINGS = {
'TEST_SETTING': {'name': 'Test Setting', 'description': 'Test setting'}
}
# mock driver implementation
self.driver_mocks = {
k: Mock()
for k in [
'init_driver',
'init_machine',
'update_machine',
'restart_machine',
]
}
for key, value in self.driver_mocks.items():
setattr(TestingDriver, key, value)
self.machine1 = MachineConfig.objects.create(
name='Test Machine 1',
machine_type='testing-type',
driver='test-driver',
active=True,
)
self.machine2 = MachineConfig.objects.create(
name='Test Machine 2',
machine_type='testing-type',
driver='test-driver',
active=True,
)
self.machine3 = MachineConfig.objects.create(
name='Test Machine 3',
machine_type='testing-type',
driver='test-driver',
active=False,
)
self.machines = [self.machine1, self.machine2, self.machine3]
# init registry
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 test_machine_lifecycle(self):
"""Test the machine lifecycle."""
# test that the registry is initialized correctly
self.assertEqual(len(registry.machines), 3)
self.assertEqual(len(registry.driver_instances), 1)
# test get_machines
self.assertEqual(len(registry.get_machines()), 2)
self.assertEqual(len(registry.get_machines(initialized=None)), 3)
self.assertEqual(len(registry.get_machines(active=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_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)
# 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)
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,
)

View File

@ -3,6 +3,7 @@
from typing import Union
from django.core.exceptions import ValidationError
from django.db.models.query import QuerySet
from django.http import JsonResponse
from django.utils.translation import gettext_lazy as _
@ -10,12 +11,17 @@ 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.exceptions import log_error
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:
@ -91,9 +97,8 @@ class LabelPrintingMixin:
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.
@ -135,7 +140,7 @@ class LabelPrintingMixin:
'user': user,
'width': label.width,
'height': label.height,
'printing_options': printing_options,
'printing_options': kwargs['printing_options'],
}
if self.BLOCKING_PRINT:

View File

@ -82,11 +82,11 @@ class LabelMixinTests(InvenTreeAPITestCase):
"""Test that the sample printing plugin is installed."""
# Get all label plugins
plugins = registry.with_mixin('labels', active=None)
self.assertEqual(len(plugins), 3)
self.assertEqual(len(plugins), 4)
# 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."""
@ -110,7 +110,7 @@ class LabelMixinTests(InvenTreeAPITestCase):
# 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]

View File

@ -0,0 +1,182 @@
"""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 common.models import InvenTreeUserSetting
from InvenTree.serializers import DependentField
from InvenTree.tasks import offload_task
from label.models import LabelTemplate
from machine.machine_types import LabelPrinterBaseDriver, LabelPrinterMachine
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)
# 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': # pragma: no cover
return None, None
machine = cast(LabelPrinterMachine, machine)
driver = machine.driver
if driver is None: # pragma: no cover
return machine, None
return machine, cast(LabelPrinterBaseDriver, 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.
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', '')
)
# 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 = {
**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)
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()
# get all available printers for each driver
machines: list[LabelPrinterMachine] = []
for driver in cast(
list[LabelPrinterBaseDriver], registry.get_drivers('label-printer')
):
machines.extend(
driver.get_printers(
template, items_to_print, request=kwargs['context']['request']
)
)
# 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: LabelPrinterMachine):
"""Construct the printers name."""
name = machine.name
if machine.location:
name += f' @ {machine.location.name}'
return name
machine = serializers.ChoiceField(choices=[])
driver_options = DependentField(
label=_('Options'),
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
)

View File

@ -0,0 +1,3 @@
from machine import BaseDriver, BaseMachineType, MachineStatus, registry
__all__ = ['registry', 'BaseDriver', 'BaseMachineType', 'MachineStatus']

View File

@ -0,0 +1,3 @@
"""just re-export the machine types from the plugin InvenTree app."""
from machine.machine_types import * # noqa: F403, F401

View File

@ -231,6 +231,8 @@ class RuleSet(models.Model):
'taggit_tag',
'taggit_taggeditem',
'flags_flagstate',
'machine_machineconfig',
'machine_machinesetting',
],
'part_category': [
'part_partcategory',

View File

@ -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.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.label_printer.LabelPrinterStatus
options:
heading_level: 4
show_bases: false
show_docstring_description: false
### LabelPrintingDriver API
::: machine.machine_types.LabelPrinterBaseDriver
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

View File

@ -0,0 +1,201 @@
---
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 gettext_lazy as _
from plugin.machine import BaseDriver, BaseMachineType, MachineStatus
class ABCBaseDriver(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 ABCMachine(BaseMachineType):
SLUG = 'abc'
NAME = _('ABC')
DESCRIPTION = _('This is an awesome machine type for ABC.')
base_driver = ABCBaseDriver
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 ABCBaseDriver
class MyXyzAbcDriverPlugin(InvenTreePlugin):
NAME = "XyzAbcDriver"
SLUG = "xyz-driver"
TITLE = "Xyz Abc Driver"
# ...
class XYZDriver(ABCBaseDriver):
SLUG = 'my-xyz-driver'
NAME = 'My XYZ driver'
DESCRIPTION = 'This is an awesome XYZ driver for a ABC machine'
```
#### 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(ABCBaseDriver):
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(ABCBaseDriver):
# ...
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(ABCBaseDriver):
# ...
def init_machine(self, machine):
# ... do some init stuff here
machine.set_status_text("Paper missing")
```

View File

@ -217,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
@ -232,6 +235,15 @@ plugins:
on_config: "docs.docs.hooks:on_config"
- macros:
include_dir: docs/_includes
- mkdocstrings:
default_handler: python
handlers:
python:
paths:
- ../InvenTree
options:
show_symbol_type_heading: true
show_symbol_type_toc: true
# Extensions
markdown_extensions:

View File

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

View File

@ -61,6 +61,8 @@ export function ChoiceField({
label={definition.label}
description={definition.description}
placeholder={definition.placeholder}
required={definition.required}
disabled={definition.disabled}
icon={definition.icon}
withinPortal={true}
/>

View File

@ -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<IndicatorProps, 'children'>;
};
/**
@ -37,34 +44,45 @@ 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 ? (
<Menu position="bottom-end">
<Menu.Target>
<Tooltip label={tooltip} hidden={!tooltip}>
<ActionIcon size="lg" radius="sm" variant="outline">
{icon}
</ActionIcon>
</Tooltip>
</Menu.Target>
<Indicator disabled={!indicatorProps} {...indicatorProps?.indicator}>
<Menu.Target>
<Tooltip label={tooltip} hidden={!tooltip}>
<ActionIcon size="lg" radius="sm" variant="outline">
{icon}
</ActionIcon>
</Tooltip>
</Menu.Target>
</Indicator>
<Menu.Dropdown>
{actions.map((action) =>
action.disabled ? null : (
<Tooltip label={action.tooltip} key={action.name}>
<Menu.Item
icon={action.icon}
onClick={() => {
if (action.onClick != undefined) {
action.onClick();
} else {
notYetImplemented();
}
}}
disabled={action.disabled}
>
{action.name}
</Menu.Item>
</Tooltip>
<Indicator
disabled={!action.indicator}
{...action.indicator}
key={action.name}
>
<Tooltip label={action.tooltip}>
<Menu.Item
icon={action.icon}
onClick={() => {
if (action.onClick != undefined) {
action.onClick();
} else {
notYetImplemented();
}
}}
disabled={action.disabled}
>
{action.name}
</Menu.Item>
</Tooltip>
</Indicator>
)
)}
</Menu.Dropdown>

View File

@ -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 <Text>{value || <Trans>None</Trans>}</Text>;
}
if (type === 'boolean') {
return <YesNoButton value={value || false} />;
}
if (type === 'code') {
return (
<Code style={{ wordWrap: 'break-word', maxWidth: '400px' }}>
{value}
</Code>
);
}
return null;
}
return (
<Group position="apart">
<Text fz="sm" fw={700}>
@ -21,13 +46,7 @@ export function InfoItem({
</Text>
<Flex>
{children}
{value !== undefined && type === 'text' ? (
<Text>{value || <Trans>None</Trans>}</Text>
) : type === 'boolean' ? (
<YesNoButton value={value || false} />
) : (
''
)}
{link ? <Link to={link}>{renderComponent()}</Link> : renderComponent()}
</Flex>
</Group>
);

View File

@ -0,0 +1,5 @@
import { IconAlertCircle } from '@tabler/icons-react';
export function UnavailableIndicator() {
return <IconAlertCircle size={18} color="red" />;
}

View File

@ -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';
@ -35,11 +44,15 @@ function DetailDrawerComponent({
position={position}
size={size}
title={
<Text size="xl" fw={600} variant="gradient">
{title}
</Text>
<Group>
<ActionIcon variant="outline" onClick={() => navigate(-1)}>
<IconChevronLeft />
</ActionIcon>
<Text size="xl" fw={600} variant="gradient">
{title}
</Text>
</Group>
}
overlayProps={{ opacity: 0.5, blur: 4 }}
>
<Stack spacing={'xs'}>
<Divider />

View File

@ -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) => {
showNotification({
@ -97,6 +100,7 @@ function SettingValue({
color: 'green'
});
settingsState.fetchSettings();
onChange?.();
}
});
}
@ -153,11 +157,13 @@ function SettingValue({
export function SettingItem({
settingsState,
setting,
shaded
shaded,
onChange
}: {
settingsState: SettingsStateProps;
setting: Setting;
shaded: boolean;
onChange?: () => void;
}) {
const theme = useMantineTheme();
@ -173,10 +179,17 @@ export function SettingItem({
<Paper style={style}>
<Group position="apart" p="3">
<Stack spacing="2" p="4px">
<Text>{setting.name}</Text>
<Text>
{setting.name}
{setting.required ? ' *' : ''}
</Text>
<Text size="xs">{setting.description}</Text>
</Stack>
<SettingValue settingsState={settingsState} setting={setting} />
<SettingValue
settingsState={settingsState}
setting={setting}
onChange={onChange}
/>
</Group>
</Paper>
);

View File

@ -1,9 +1,11 @@
import { Trans } from '@lingui/macro';
import { Stack, Text } from '@mantine/core';
import React, { useEffect, useMemo, useRef } from 'react';
import { useStore } from 'zustand';
import {
SettingsStateProps,
createMachineSettingsState,
createPluginSettingsState,
useGlobalSettingsState,
useUserSettingsState
@ -15,10 +17,12 @@ import { SettingItem } from './SettingItem';
*/
export function SettingList({
settingsState,
keys
keys,
onChange
}: {
settingsState: SettingsStateProps;
keys?: string[];
onChange?: () => void;
}) {
useEffect(() => {
settingsState.fetchSettings();
@ -44,6 +48,7 @@ export function SettingList({
settingsState={settingsState}
setting={setting}
shaded={i % 2 === 0}
onChange={onChange}
/>
) : (
<Text size="sm" italic color="red">
@ -53,6 +58,11 @@ export function SettingList({
</React.Fragment>
);
})}
{(keys || allKeys).length === 0 && (
<Text italic>
<Trans>No settings specified</Trans>
</Text>
)}
</Stack>
</>
);
@ -78,3 +88,23 @@ export function PluginSettingList({ pluginPk }: { pluginPk: string }) {
return <SettingList settingsState={pluginSettings} />;
}
export function MachineSettingList({
machinePk,
configType,
onChange
}: {
machinePk: string;
configType: 'M' | 'D';
onChange?: () => void;
}) {
const machineSettingsStore = useRef(
createMachineSettingsState({
machine: machinePk,
configType: configType
})
).current;
const machineSettings = useStore(machineSettingsStore);
return <SettingList settingsState={machineSettings} onChange={onChange} />;
}

View File

@ -100,6 +100,15 @@ export enum ApiEndpoints {
plugin_activate = 'plugins/:id/activate/',
plugin_uninstall = 'plugins/:id/uninstall/',
// Machine API endpoints
machine_types_list = 'machine/types/',
machine_driver_list = 'machine/drivers/',
machine_registry_status = 'machine/status/',
machine_list = 'machine/',
machine_restart = 'machine/:machine/restart/',
machine_setting_list = 'machine/:machine/settings/',
machine_setting_detail = 'machine/:machine/settings/:config_type/',
// Miscellaneous API endpoints
error_report_list = 'error-report/',
project_code_list = 'project-code/',

View File

@ -2,6 +2,7 @@ import { Trans, t } from '@lingui/macro';
import { Divider, Paper, SimpleGrid, Stack, Text, Title } from '@mantine/core';
import {
IconCpu,
IconDevicesPc,
IconExclamationCircle,
IconList,
IconListDetails,
@ -29,6 +30,10 @@ const PluginManagementPanel = Loadable(
lazy(() => import('./PluginManagementPanel'))
);
const MachineManagementPanel = Loadable(
lazy(() => import('./MachineManagementPanel'))
);
const ErrorReportTable = Loadable(
lazy(() => import('../../../../tables/settings/ErrorTable'))
);
@ -95,6 +100,12 @@ export default function AdminCenter() {
label: t`Plugins`,
icon: <IconPlugConnected />,
content: <PluginManagementPanel />
},
{
name: 'machine',
label: t`Machines`,
icon: <IconDevicesPc />,
content: <MachineManagementPanel />
}
];
}, []);

View File

@ -0,0 +1,76 @@
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 { ApiEndpoints } from '../../../../enums/ApiEndpoints';
import { apiUrl } from '../../../../states/ApiState';
import { MachineListTable } from '../../../../tables/machine/MachineListTable';
import { MachineTypeListTable } from '../../../../tables/machine/MachineTypeTable';
interface MachineRegistryStatusI {
registry_errors: { message: string }[];
}
export default function MachineManagementPanel() {
const { data: registryStatus, refetch } = useQuery<MachineRegistryStatusI>({
queryKey: ['machine-registry-status'],
queryFn: () =>
api
.get(apiUrl(ApiEndpoints.machine_registry_status))
.then((res) => res.data),
staleTime: 10 * 1000
});
return (
<Stack>
<MachineListTable props={{}} />
<Space h="10px" />
<Stack spacing={'xs'}>
<Title order={5}>
<Trans>Machine types</Trans>
</Title>
<MachineTypeListTable props={{}} />
</Stack>
<Space h="10px" />
<Stack spacing={'xs'}>
<Group>
<Title order={5}>
<Trans>Machine Error Stack</Trans>
</Title>
<ActionIcon variant="outline" onClick={() => refetch()}>
<IconRefresh />
</ActionIcon>
</Group>
{registryStatus?.registry_errors &&
registryStatus.registry_errors.length === 0 ? (
<Text italic>
<Trans>There are no machine registry errors.</Trans>
</Text>
) : (
<List>
{registryStatus?.registry_errors?.map((error, i) => (
<List.Item key={i}>
<Code>{error.message}</Code>
</List.Item>
))}
</List>
)}
</Stack>
</Stack>
);
}

View File

@ -132,6 +132,54 @@ export const createPluginSettingsState = ({
}));
};
/**
* 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<SettingsStateProps>()((set, get) => ({
settings: [],
lookup: {},
endpoint: ApiEndpoints.machine_setting_detail,
pathParams,
fetchSettings: async () => {
await api
.get(apiUrl(ApiEndpoints.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
);
});
},
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);
}
}));
};
/*
return a lookup dictionary for the value of the provided Setting list
*/

View File

@ -79,6 +79,7 @@ export interface Setting {
typ: SettingTyp;
plugin?: string;
method?: string;
required?: boolean;
}
export interface SettingChoice {

View File

@ -0,0 +1,604 @@
import { Trans, t } from '@lingui/macro';
import {
ActionIcon,
Badge,
Box,
Card,
Code,
Flex,
Group,
Indicator,
List,
LoadingOverlay,
Space,
Stack,
Text,
Title
} from '@mantine/core';
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';
import { Link } from 'react-router-dom';
import { api } from '../../App';
import { AddItemButton } from '../../components/buttons/AddItemButton';
import {
ActionDropdown,
DeleteItemAction,
EditItemAction
} from '../../components/items/ActionDropdown';
import { InfoItem } from '../../components/items/InfoItem';
import { UnavailableIndicator } from '../../components/items/UnavailableIndicator';
import { YesNoButton } from '../../components/items/YesNoButton';
import { DetailDrawer } from '../../components/nav/DetailDrawer';
import {
StatusRenderer,
TableStatusRenderer
} from '../../components/render/StatusRenderer';
import { MachineSettingList } from '../../components/settings/SettingList';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { openDeleteApiForm, openEditApiForm } from '../../functions/forms';
import { useCreateApiFormModal } from '../../hooks/UseForm';
import { useTable } from '../../hooks/UseTable';
import { apiUrl } from '../../states/ApiState';
import { TableColumn } from '../Column';
import { BooleanColumn } from '../ColumnRenderers';
import { InvenTreeTable, InvenTreeTableProps } from '../InvenTreeTable';
import { MachineDriverI, MachineTypeI } from './MachineTypeTable';
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;
restart_required: boolean;
}
function MachineStatusIndicator({ machine }: { machine: MachineI }) {
const sx = { marginLeft: '4px' };
// machine is not active, show a gray dot
if (!machine.active) {
return (
<Indicator sx={sx} color="gray">
<Box></Box>
</Indicator>
);
}
// 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 (
<Indicator processing={processing} sx={sx} color={color}>
<Box></Box>
</Indicator>
);
}
export function useMachineTypeDriver({
includeTypes = true,
includeDrivers = true
}: { includeTypes?: boolean; includeDrivers?: boolean } = {}) {
const {
data: machineTypes,
isFetching: isMachineTypesFetching,
refetch: refreshMachineTypes
} = useQuery<MachineTypeI[]>({
enabled: includeTypes,
queryKey: ['machine-types'],
queryFn: () =>
api.get(apiUrl(ApiEndpoints.machine_types_list)).then((res) => res.data),
staleTime: 10 * 1000
});
const {
data: machineDrivers,
isFetching: isMachineDriversFetching,
refetch: refreshDrivers
} = useQuery<MachineDriverI[]>({
enabled: includeDrivers,
queryKey: ['machine-drivers'],
queryFn: () =>
api.get(apiUrl(ApiEndpoints.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,
refreshTable
}: {
machinePk: string;
refreshTable: () => void;
}) {
const navigate = useNavigate();
const {
data: machine,
refetch,
isFetching: isMachineFetching
} = useQuery<MachineI>({
enabled: true,
queryKey: ['machine-detail', machinePk],
queryFn: () =>
api
.get(apiUrl(ApiEndpoints.machine_list, machinePk))
.then((res) => res.data)
});
const {
machineTypes,
machineDrivers,
isFetching: isMachineTypeDriverFetching
} = useMachineTypeDriver();
const isFetching = isMachineFetching || isMachineTypeDriverFetching;
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]
);
const refreshAll = useCallback(() => {
refetch();
refreshTable();
}, [refetch, refreshTable]);
const restartMachine = useCallback(
(machinePk: string) => {
api
.post(
apiUrl(ApiEndpoints.machine_restart, undefined, {
machine: machinePk
})
)
.then(() => {
refreshAll();
notifications.show({
message: t`Machine restarted`,
color: 'green',
icon: <IconCheck size="1rem" />
});
});
},
[refreshAll]
);
return (
<Stack spacing="xs">
<Group position="apart">
<Box></Box>
<Group>
{machine && <MachineStatusIndicator machine={machine} />}
<Title order={4}>{machine?.name}</Title>
</Group>
<Group>
{machine?.restart_required && (
<Badge color="red">
<Trans>Restart required</Trans>
</Badge>
)}
<ActionDropdown
tooltip={t`Machine Actions`}
icon={<IconDots />}
actions={[
EditItemAction({
tooltip: t`Edit machine`,
onClick: () => {
openEditApiForm({
title: t`Edit machine`,
url: ApiEndpoints.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: ApiEndpoints.machine_list,
pk: machinePk,
preFormContent: (
<Text>{t`Are you sure you want to remove the machine "${machine?.name}"?`}</Text>
),
onFormSuccess: () => {
refreshTable();
navigate(-1);
}
});
}
}),
{
icon: <IconRefresh />,
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)
}
]}
/>
</Group>
</Group>
<Card withBorder>
<Stack spacing="md">
<Group position="apart">
<Title order={4}>
<Trans>Machine information</Trans>
</Title>
<ActionIcon variant="outline" onClick={() => refetch()}>
<IconRefresh />
</ActionIcon>
</Group>
<Stack pos="relative" spacing="xs">
<LoadingOverlay visible={isFetching} overlayOpacity={0} />
<InfoItem name={t`Machine Type`}>
<Group spacing="xs">
{machineType ? (
<Link to={`../type-${machine?.machine_type}`}>
<Text>{machineType.name}</Text>
</Link>
) : (
<Text>{machine?.machine_type}</Text>
)}
{machine && !machineType && <UnavailableIndicator />}
</Group>
</InfoItem>
<InfoItem name={t`Machine Driver`}>
<Group spacing="xs">
{machineDriver ? (
<Link to={`../driver-${machine?.driver}`}>
<Text>{machineDriver.name}</Text>
</Link>
) : (
<Text>{machine?.driver}</Text>
)}
{!machine?.is_driver_available && <UnavailableIndicator />}
</Group>
</InfoItem>
<InfoItem name={t`Initialized`}>
<YesNoButton value={machine?.initialized || false} />
</InfoItem>
<InfoItem name={t`Active`}>
<YesNoButton value={machine?.active || false} />
</InfoItem>
<InfoItem name={t`Status`}>
<Flex direction="column">
{machine?.status === -1 ? (
<Text fz="xs">No status</Text>
) : (
StatusRenderer({
status: `${machine?.status || -1}`,
type: `MachineStatus__${machine?.status_model}` as any
})
)}
<Text fz="sm">{machine?.status_text}</Text>
</Flex>
</InfoItem>
<Group position="apart" spacing="xs">
<Text fz="sm" fw={700}>
<Trans>Errors</Trans>:
</Text>
{machine && machine?.machine_errors.length > 0 ? (
<Badge color="red" sx={{ marginLeft: '10px' }}>
{machine?.machine_errors.length}
</Badge>
) : (
<Text fz="xs">
<Trans>No errors reported</Trans>
</Text>
)}
<List w="100%">
{machine?.machine_errors.map((error, i) => (
<List.Item key={i}>
<Code>{error}</Code>
</List.Item>
))}
</List>
</Group>
</Stack>
</Stack>
</Card>
<Space h="10px" />
{machine?.is_driver_available && (
<>
<Card withBorder>
<Title order={5} pb={4}>
<Trans>Machine Settings</Trans>
</Title>
<MachineSettingList
machinePk={machinePk}
configType="M"
onChange={refreshAll}
/>
</Card>
<Card withBorder>
<Title order={5} pb={4}>
<Trans>Driver Settings</Trans>
</Title>
<MachineSettingList
machinePk={machinePk}
configType="D"
onChange={refreshAll}
/>
</Card>
</>
)}
</Stack>
);
}
/**
* Table displaying list of available plugins
*/
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');
const navigate = useNavigate();
const machineTableColumns = useMemo<TableColumn<MachineI>[]>(
() => [
{
accessor: 'name',
sortable: true,
render: function (record) {
return (
<Group position="left" noWrap>
<MachineStatusIndicator machine={record} />
<Text>{record.name}</Text>
{record.restart_required && (
<Badge color="red">
<Trans>Restart required</Trans>
</Badge>
)}
</Group>
);
}
},
{
accessor: 'machine_type',
sortable: true,
render: (record) => {
const machineType = machineTypes?.find(
(m) => m.slug === record.machine_type
);
return (
<Group spacing="xs">
<Text>
{machineType ? machineType.name : record.machine_type}
</Text>
{machineTypes && !machineType && <UnavailableIndicator />}
</Group>
);
}
},
{
accessor: 'driver',
sortable: true,
render: (record) => {
const driver = machineDrivers?.find((d) => d.slug === record.driver);
return (
<Group spacing="xs">
<Text>{driver ? driver.name : record.driver}</Text>
{!record.is_driver_available && <UnavailableIndicator />}
</Group>
);
}
},
BooleanColumn({
accessor: 'initialized'
}),
BooleanColumn({
accessor: 'active'
}),
{
accessor: 'status',
sortable: false,
render: (record) => {
const renderer = TableStatusRenderer(
`MachineStatus__${record.status_model}` as any
);
if (renderer && record.status !== -1) {
return renderer(record);
}
}
}
],
[machineTypes]
);
const [createFormMachineType, setCreateFormMachineType] = useState<
null | string
>(null);
const createFormDriverOptions = useMemo(() => {
if (!machineDrivers) return [];
return machineDrivers
.filter((d) => d.machine_type === createFormMachineType)
.map((d) => ({
value: d.slug,
display_name: d.name
}));
}, [machineDrivers, createFormMachineType]);
const createMachineForm = useCreateApiFormModal({
title: t`Create machine`,
url: ApiEndpoints.machine_list,
fields: {
name: {},
machine_type: {
hidden: !!createProps?.machine_type,
...(createProps?.machine_type
? { value: createProps.machine_type }
: {}),
field_type: 'choice',
choices: machineTypes
? machineTypes.map((t) => ({
value: t.slug,
display_name: t.name
}))
: [],
onValueChange: (value) => setCreateFormMachineType(value)
},
driver: {
hidden: !!createProps?.driver,
...(createProps?.driver ? { value: createProps.driver } : {}),
field_type: 'choice',
disabled: !createFormMachineType,
choices: createFormDriverOptions
},
active: {}
},
onFormSuccess: (data) => {
table.refreshTable();
navigate(
renderMachineDrawer ? `machine-${data.pk}/` : `../machine-${data.pk}/`
);
},
onClose: () => {
setCreateFormMachineType(null);
}
});
const tableActions = useMemo(() => {
return [
<AddItemButton
variant="outline"
onClick={() => {
setCreateFormMachineType(null);
createMachineForm.open();
}}
/>
];
}, [createMachineForm.open]);
return (
<>
{createMachineForm.modal}
{renderMachineDrawer && (
<DetailDrawer
title={t`Machine detail`}
size={'lg'}
renderContent={(id) => {
if (!id || !id.startsWith('machine-')) return false;
return (
<MachineDrawer
machinePk={id.replace('machine-', '')}
refreshTable={table.refreshTable}
/>
);
}}
/>
)}
<InvenTreeTable
url={apiUrl(ApiEndpoints.machine_list)}
tableState={table}
columns={machineTableColumns}
props={{
...props,
enableDownload: false,
onRowClick: (machine) =>
navigate(
renderMachineDrawer
? `machine-${machine.pk}/`
: `../machine-${machine.pk}/`
),
tableActions,
params: {
...props.params
},
tableFilters: [
{
name: 'active',
type: 'boolean'
},
{
name: 'machine_type',
type: 'choice',
choiceFunction: () =>
machineTypes
? machineTypes.map((t) => ({ value: t.slug, label: t.name }))
: []
},
{
name: 'driver',
type: 'choice',
choiceFunction: () =>
machineDrivers
? machineDrivers.map((d) => ({
value: d.slug,
label: d.name
}))
: []
}
]
}}
/>
</>
);
}

View File

@ -0,0 +1,364 @@
import { Trans, t } from '@lingui/macro';
import {
ActionIcon,
Badge,
Card,
Code,
Group,
List,
LoadingOverlay,
Stack,
Text,
Title
} from '@mantine/core';
import { IconRefresh } from '@tabler/icons-react';
import { useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { InfoItem } from '../../components/items/InfoItem';
import { DetailDrawer } from '../../components/nav/DetailDrawer';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { useTable } from '../../hooks/UseTable';
import { apiUrl } from '../../states/ApiState';
import { TableColumn } from '../Column';
import { BooleanColumn } from '../ColumnRenderers';
import { InvenTreeTable, InvenTreeTableProps } from '../InvenTreeTable';
import { MachineListTable, 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;
driver_errors: 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<TableColumn<MachineDriverI>[]>(
() => [
{
accessor: 'name',
title: t`Name`
},
{
accessor: 'description',
title: t`Description`
},
BooleanColumn({
accessor: 'is_builtin',
title: t`Builtin driver`
})
],
[]
);
return (
<Stack>
<Group position="center">
<Title order={4}>
{machineType ? machineType.name : machineTypeSlug}
</Title>
</Group>
{!machineType && (
<Text italic>
<Trans>Machine type not found.</Trans>
</Text>
)}
<Card withBorder>
<Stack spacing="md">
<Group position="apart">
<Title order={4}>
<Trans>Machine type information</Trans>
</Title>
<ActionIcon variant="outline" onClick={() => refresh()}>
<IconRefresh />
</ActionIcon>
</Group>
<Stack pos="relative" spacing="xs">
<LoadingOverlay visible={isFetching} overlayOpacity={0} />
<InfoItem name={t`Name`} value={machineType?.name} type="text" />
<InfoItem name={t`Slug`} value={machineType?.slug} type="text" />
<InfoItem
name={t`Description`}
value={machineType?.description}
type="text"
/>
{!machineType?.is_builtin && (
<InfoItem
name={t`Provider plugin`}
value={machineType?.provider_plugin?.name}
type="text"
link={
machineType?.provider_plugin?.pk !== null
? `../../plugin/${machineType?.provider_plugin?.pk}/`
: undefined
}
/>
)}
<InfoItem
name={t`Provider file`}
value={machineType?.provider_file}
type="code"
/>
<InfoItem
name={t`Builtin`}
value={machineType?.is_builtin}
type="boolean"
/>
</Stack>
</Stack>
</Card>
<Card withBorder>
<Stack spacing="md">
<Title order={4}>
<Trans>Available drivers</Trans>
</Title>
<InvenTreeTable
url={apiUrl(ApiEndpoints.machine_driver_list)}
tableState={table}
columns={machineDriverTableColumns}
props={{
dataFormatter: (data: any) => {
return data.filter(
(d: any) => d.machine_type === machineTypeSlug
);
},
enableDownload: false,
enableSearch: false,
onRowClick: (machine) => navigate(`../driver-${machine.slug}/`)
}}
/>
</Stack>
</Card>
</Stack>
);
}
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 (
<Stack>
<Group position="center">
<Title order={4}>
{machineDriver ? machineDriver.name : machineDriverSlug}
</Title>
</Group>
{!machineDriver && (
<Text italic>
<Trans>Machine driver not found.</Trans>
</Text>
)}
<Card withBorder>
<Stack spacing="md">
<Group position="apart">
<Title order={4}>
<Trans>Machine driver information</Trans>
</Title>
<ActionIcon variant="outline" onClick={() => refresh()}>
<IconRefresh />
</ActionIcon>
</Group>
<Stack pos="relative" spacing="xs">
<LoadingOverlay visible={isFetching} overlayOpacity={0} />
<InfoItem name={t`Name`} value={machineDriver?.name} type="text" />
<InfoItem name={t`Slug`} value={machineDriver?.slug} type="text" />
<InfoItem
name={t`Description`}
value={machineDriver?.description}
type="text"
/>
<InfoItem
name={t`Machine type`}
value={
machineType ? machineType.name : machineDriver?.machine_type
}
type="text"
link={
machineType
? `../type-${machineDriver?.machine_type}`
: undefined
}
/>
{!machineDriver?.is_builtin && (
<InfoItem
name={t`Provider plugin`}
value={machineDriver?.provider_plugin?.name}
type="text"
link={
machineDriver?.provider_plugin?.pk !== null
? `../../plugin/${machineDriver?.provider_plugin?.pk}/`
: undefined
}
/>
)}
<InfoItem
name={t`Provider file`}
value={machineDriver?.provider_file}
type="code"
/>
<InfoItem
name={t`Builtin`}
value={machineDriver?.is_builtin}
type="boolean"
/>
<Group position="apart" spacing="xs">
<Text fz="sm" fw={700}>
<Trans>Errors</Trans>:
</Text>
{machineDriver && machineDriver?.driver_errors.length > 0 ? (
<Badge color="red" sx={{ marginLeft: '10px' }}>
{machineDriver.driver_errors.length}
</Badge>
) : (
<Text fz="xs">
<Trans>No errors reported</Trans>
</Text>
)}
<List w="100%">
{machineDriver?.driver_errors.map((error, i) => (
<List.Item key={i}>
<Code>{error}</Code>
</List.Item>
))}
</List>
</Group>
</Stack>
</Stack>
</Card>
<Card withBorder>
<Stack spacing="md">
<Title order={4}>
<Trans>Machines</Trans>
</Title>
<MachineListTable
props={{ params: { driver: machineDriverSlug } }}
renderMachineDrawer={false}
createProps={{
machine_type: machineDriver?.machine_type,
driver: machineDriverSlug
}}
/>
</Stack>
</Card>
</Stack>
);
}
/**
* Table displaying list of available machine types
*/
export function MachineTypeListTable({
props
}: {
props: InvenTreeTableProps;
}) {
const table = useTable('machineTypes');
const navigate = useNavigate();
const machineTypeTableColumns = useMemo<TableColumn<MachineTypeI>[]>(
() => [
{
accessor: 'name',
title: t`Name`
},
{
accessor: 'description',
title: t`Description`
},
BooleanColumn({
accessor: 'is_builtin',
title: t`Builtin type`
})
],
[]
);
return (
<>
<DetailDrawer
title={t`Machine type detail`}
size={'lg'}
renderContent={(id) => {
if (!id || !id.startsWith('type-')) return false;
return (
<MachineTypeDrawer machineTypeSlug={id.replace('type-', '')} />
);
}}
/>
<DetailDrawer
title={t`Machine driver detail`}
size={'lg'}
renderContent={(id) => {
if (!id || !id.startsWith('driver-')) return false;
return (
<MachineDriverDrawer
machineDriverSlug={id.replace('driver-', '')}
/>
);
}}
/>
<InvenTreeTable
url={apiUrl(ApiEndpoints.machine_types_list)}
tableState={table}
columns={machineTypeTableColumns}
props={{
...props,
enableDownload: false,
enableSearch: false,
onRowClick: (machine) => navigate(`type-${machine.slug}/`),
params: {
...props.params
}
}}
/>
</>
);
}