Skip to content

Commit

Permalink
Machine integration (#4824)
Browse files Browse the repository at this point in the history
* 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>
  • Loading branch information
wolflu05 and matmair authored Feb 14, 2024
1 parent aed7754 commit aa7eaaa
Show file tree
Hide file tree
Showing 50 changed files with 4,243 additions and 61 deletions.
11 changes: 10 additions & 1 deletion InvenTree/InvenTree/api_version.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
6 changes: 5 additions & 1 deletion InvenTree/InvenTree/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import os.path
import re
from decimal import Decimal, InvalidOperation
from typing import Set, Type, TypeVar
from wsgiref.util import FileWrapper

from django.conf import settings
Expand Down Expand Up @@ -885,7 +886,10 @@ def get_target(self, obj):
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]
Expand Down
106 changes: 106 additions & 0 deletions InvenTree/InvenTree/helpers_mixin.py
Original file line number Diff line number Diff line change
@@ -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
6 changes: 5 additions & 1 deletion InvenTree/InvenTree/metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from rest_framework.metadata import SimpleMetadata
from rest_framework.utils import model_meta

import common.models
import InvenTree.permissions
import users.models
from InvenTree.helpers import str2bool
Expand Down Expand Up @@ -208,7 +209,10 @@ def get_serializer_info(self, serializer):
pk = kwargs[field]
break

if pk is not None:
if issubclass(model_class, common.models.BaseInvenTreeSetting):
instance = model_class.get_setting_object(**kwargs, create=False)

elif pk is not None:
try:
instance = model_class.objects.get(pk=pk)
except (ValueError, model_class.DoesNotExist):
Expand Down
11 changes: 9 additions & 2 deletions InvenTree/InvenTree/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,8 +158,15 @@ def visit_parent(node):

# check if the request data contains the dependent fields, otherwise skip getting the child
for f in self.depends_on:
if not data.get(f, None):
return
if data.get(f, None) is None:
if (
self.parent
and (v := getattr(self.parent.fields[f], 'default', None))
is not None
):
data[f] = v
else:
return

# partially validate the data for options requests that set raise_exception while calling .get_child(...)
if raise_exception:
Expand Down
1 change: 1 addition & 0 deletions InvenTree/InvenTree/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,7 @@
'report.apps.ReportConfig',
'stock.apps.StockConfig',
'users.apps.UsersConfig',
'machine.apps.MachineConfig',
'web',
'generic',
'InvenTree.apps.InvenTreeConfig', # InvenTree app runs last
Expand Down
96 changes: 96 additions & 0 deletions InvenTree/InvenTree/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
import InvenTree.tasks
from common.models import CustomUnit, InvenTreeSetting
from common.settings import currency_codes
from InvenTree.helpers_mixin import ClassProviderMixin, ClassValidationMixin
from InvenTree.sanitizer import sanitize_svg
from InvenTree.unit_test import InvenTreeTestCase
from part.models import Part, PartCategory
Expand Down Expand Up @@ -1317,3 +1318,98 @@ def set_timestamp(value):
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())
2 changes: 2 additions & 0 deletions InvenTree/InvenTree/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import common.api
import company.api
import label.api
import machine.api
import order.api
import part.api
import plugin.api
Expand Down Expand Up @@ -83,6 +84,7 @@
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)),
Expand Down
11 changes: 10 additions & 1 deletion InvenTree/common/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -525,7 +525,11 @@ def get_setting_choices(cls, key, **kwargs):

if callable(choices):
# Evaluate the function (we expect it will return a list of tuples...)
return choices()
try:
# Attempt to pass the kwargs to the function, if it doesn't expect them, ignore and call without
return choices(**kwargs)
except TypeError:
return choices()

return choices

Expand Down Expand Up @@ -2359,6 +2363,11 @@ class Meta:
'default': True,
'validator': bool,
},
'LAST_USED_PRINTING_MACHINES': {
'name': _('Last used printing machines'),
'description': _('Save the last used printing machines for a user'),
'default': '',
},
}

typ = 'user'
Expand Down
3 changes: 3 additions & 0 deletions InvenTree/common/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -150,6 +152,7 @@ class CustomMeta:
'model_name',
'api_url',
'typ',
'required',
]

# set Meta class
Expand Down
16 changes: 11 additions & 5 deletions InvenTree/generic/states/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,10 +75,16 @@ def get(self, request, *args, **kwargs):
"""Perform a GET request to learn information about status codes."""
data = {}

for status_class in StatusCode.__subclasses__():
data[status_class.__name__] = {
'class': status_class.__name__,
'values': status_class.dict(),
}
def discover_status_codes(parent_status_class, prefix=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)
5 changes: 4 additions & 1 deletion InvenTree/label/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,10 @@ def print(self, request, items_to_print):

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)
Expand Down
4 changes: 4 additions & 0 deletions InvenTree/machine/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from machine.machine_type import BaseDriver, BaseMachineType, MachineStatus
from machine.registry import registry

__all__ = ['registry', 'BaseMachineType', 'BaseDriver', 'MachineStatus']
Loading

0 comments on commit aa7eaaa

Please sign in to comment.