mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
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:
parent
aed7754bc2
commit
aa7eaaab3a
@ -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
|
||||
|
||||
|
@ -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]
|
||||
|
106
InvenTree/InvenTree/helpers_mixin.py
Normal file
106
InvenTree/InvenTree/helpers_mixin.py
Normal 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
|
@ -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):
|
||||
|
@ -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:
|
||||
|
@ -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
|
||||
|
@ -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())
|
||||
|
@ -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)),
|
||||
|
@ -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'
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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
4
InvenTree/machine/__init__.py
Executable 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
48
InvenTree/machine/admin.py
Executable 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
251
InvenTree/machine/api.py
Normal 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
43
InvenTree/machine/apps.py
Executable 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')
|
375
InvenTree/machine/machine_type.py
Normal file
375
InvenTree/machine/machine_type.py
Normal 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
|
11
InvenTree/machine/machine_types/__init__.py
Normal file
11
InvenTree/machine/machine_types/__init__.py
Normal file
@ -0,0 +1,11 @@
|
||||
from machine.machine_types.label_printer import (
|
||||
LabelPrinterBaseDriver,
|
||||
LabelPrinterMachine,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# machine types
|
||||
'LabelPrinterMachine',
|
||||
# base drivers
|
||||
'LabelPrinterBaseDriver',
|
||||
]
|
265
InvenTree/machine/machine_types/label_printer.py
Normal file
265
InvenTree/machine/machine_types/label_printer.py
Normal 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)
|
39
InvenTree/machine/migrations/0001_initial.py
Normal file
39
InvenTree/machine/migrations/0001_initial.py
Normal 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')},
|
||||
},
|
||||
),
|
||||
]
|
0
InvenTree/machine/migrations/__init__.py
Executable file
0
InvenTree/machine/migrations/__init__.py
Executable file
197
InvenTree/machine/models.py
Executable file
197
InvenTree/machine/models.py
Executable 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)
|
226
InvenTree/machine/registry.py
Normal file
226
InvenTree/machine/registry.py
Normal 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()
|
206
InvenTree/machine/serializers.py
Normal file
206
InvenTree/machine/serializers.py
Normal 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()
|
307
InvenTree/machine/test_api.py
Normal file
307
InvenTree/machine/test_api.py
Normal 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
303
InvenTree/machine/tests.py
Executable 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,
|
||||
)
|
@ -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:
|
||||
|
@ -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]
|
||||
|
||||
|
182
InvenTree/plugin/builtin/labels/inventree_machine.py
Normal file
182
InvenTree/plugin/builtin/labels/inventree_machine.py
Normal 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
|
||||
)
|
3
InvenTree/plugin/machine/__init__.py
Normal file
3
InvenTree/plugin/machine/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
from machine import BaseDriver, BaseMachineType, MachineStatus, registry
|
||||
|
||||
__all__ = ['registry', 'BaseDriver', 'BaseMachineType', 'MachineStatus']
|
3
InvenTree/plugin/machine/machine_types.py
Normal file
3
InvenTree/plugin/machine/machine_types.py
Normal file
@ -0,0 +1,3 @@
|
||||
"""just re-export the machine types from the plugin InvenTree app."""
|
||||
|
||||
from machine.machine_types import * # noqa: F403, F401
|
@ -231,6 +231,8 @@ class RuleSet(models.Model):
|
||||
'taggit_tag',
|
||||
'taggit_taggeditem',
|
||||
'flags_flagstate',
|
||||
'machine_machineconfig',
|
||||
'machine_machinesetting',
|
||||
],
|
||||
'part_category': [
|
||||
'part_partcategory',
|
||||
|
35
docs/docs/extend/machines/label_printer.md
Normal file
35
docs/docs/extend/machines/label_printer.md
Normal 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
|
201
docs/docs/extend/machines/overview.md
Normal file
201
docs/docs/extend/machines/overview.md
Normal 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")
|
||||
```
|
@ -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:
|
||||
|
@ -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
|
||||
|
@ -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}
|
||||
/>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -0,0 +1,5 @@
|
||||
import { IconAlertCircle } from '@tabler/icons-react';
|
||||
|
||||
export function UnavailableIndicator() {
|
||||
return <IconAlertCircle size={18} color="red" />;
|
||||
}
|
@ -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 />
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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} />;
|
||||
}
|
||||
|
@ -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/',
|
||||
|
@ -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 />
|
||||
}
|
||||
];
|
||||
}, []);
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
@ -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
|
||||
*/
|
||||
|
@ -79,6 +79,7 @@ export interface Setting {
|
||||
typ: SettingTyp;
|
||||
plugin?: string;
|
||||
method?: string;
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
export interface SettingChoice {
|
||||
|
604
src/frontend/src/tables/machine/MachineListTable.tsx
Normal file
604
src/frontend/src/tables/machine/MachineListTable.tsx
Normal 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
|
||||
}))
|
||||
: []
|
||||
}
|
||||
]
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
364
src/frontend/src/tables/machine/MachineTypeTable.tsx
Normal file
364
src/frontend/src/tables/machine/MachineTypeTable.tsx
Normal 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
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
Loading…
Reference in New Issue
Block a user