mirror of
https://github.com/inventree/InvenTree.git
synced 2025-06-19 05:25:42 +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:
@ -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',
|
||||
|
Reference in New Issue
Block a user