Merge branch 'inventree:master' into matmair/issue2279

This commit is contained in:
Matthias Mair 2022-01-12 14:36:04 +01:00 committed by GitHub
commit c490574082
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 639 additions and 477 deletions

View File

@ -5,8 +5,6 @@ Main JSON interface views
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import logging
from django.utils.translation import ugettext_lazy as _
from django.http import JsonResponse
@ -21,14 +19,7 @@ from .views import AjaxView
from .version import inventreeVersion, inventreeApiVersion, inventreeInstanceName
from .status import is_worker_running
from plugin.plugins import load_action_plugins
logger = logging.getLogger("inventree")
logger.info("Loading action plugins...")
action_plugins = load_action_plugins()
from plugin import registry
class InfoView(AjaxView):
@ -110,10 +101,11 @@ class ActionPluginView(APIView):
'error': _("No action specified")
})
for plugin_class in action_plugins:
if plugin_class.action_name() == action:
plugin = plugin_class(request.user, data=data)
action_plugins = registry.with_mixin('action')
for plugin in action_plugins:
if plugin.action_name() == action:
# TODO @matmair use easier syntax once InvenTree 0.7.0 is released
plugin.init(request.user, data=data)
plugin.perform_action()

View File

@ -880,7 +880,7 @@ PLUGINS_ENABLED = _is_true(get_setting(
PLUGIN_FILE = get_plugin_file()
# Plugin Directories (local plugins will be loaded from these directories)
PLUGIN_DIRS = ['plugin.builtin', ]
PLUGIN_DIRS = ['plugin.builtin', 'barcodes.plugins', ]
if not TESTING:
# load local deploy directory in prod

View File

@ -13,7 +13,7 @@ from stock.models import StockItem
from stock.serializers import StockItemSerializer
from barcodes.barcode import hash_barcode
from plugin.plugins import load_barcode_plugins
from plugin import registry
class BarcodeScan(APIView):
@ -53,18 +53,19 @@ class BarcodeScan(APIView):
if 'barcode' not in data:
raise ValidationError({'barcode': _('Must provide barcode_data parameter')})
plugins = load_barcode_plugins()
plugins = registry.with_mixin('barcode')
barcode_data = data.get('barcode')
# Look for a barcode plugin which knows how to deal with this barcode
plugin = None
for plugin_class in plugins:
plugin_instance = plugin_class(barcode_data)
for current_plugin in plugins:
# TODO @matmair make simpler after InvenTree 0.7.0 release
current_plugin.init(barcode_data)
if plugin_instance.validate():
plugin = plugin_instance
if current_plugin.validate():
plugin = current_plugin
break
match_found = False
@ -160,15 +161,16 @@ class BarcodeAssign(APIView):
except (ValueError, StockItem.DoesNotExist):
raise ValidationError({'stockitem': _('No matching stock item found')})
plugins = load_barcode_plugins()
plugins = registry.with_mixin('barcode')
plugin = None
for plugin_class in plugins:
plugin_instance = plugin_class(barcode_data)
for current_plugin in plugins:
# TODO @matmair make simpler after InvenTree 0.7.0 release
current_plugin.init(barcode_data)
if plugin_instance.validate():
plugin = plugin_instance
if current_plugin.validate():
plugin = current_plugin
break
match_found = False

View File

@ -1,139 +1,20 @@
# -*- coding: utf-8 -*-
import warnings
import string
import hashlib
import logging
import plugin.builtin.barcode.mixins as mixin
import plugin.integration
from stock.models import StockItem
from stock.serializers import StockItemSerializer, LocationSerializer
from part.serializers import PartSerializer
hash_barcode = mixin.hash_barcode
logger = logging.getLogger('inventree')
def hash_barcode(barcode_data):
class BarcodePlugin(mixin.BarcodeMixin, plugin.integration.IntegrationPluginBase):
"""
Calculate an MD5 hash of barcode data.
HACK: Remove any 'non printable' characters from the hash,
as it seems browers will remove special control characters...
TODO: Work out a way around this!
Legacy barcode plugin definition - will be replaced
Please use the new Integration Plugin API and the BarcodeMixin
"""
barcode_data = str(barcode_data).strip()
printable_chars = filter(lambda x: x in string.printable, barcode_data)
barcode_data = ''.join(list(printable_chars))
hash = hashlib.md5(str(barcode_data).encode())
return str(hash.hexdigest())
class BarcodePlugin:
"""
Base class for barcode handling.
Custom barcode plugins should extend this class as necessary.
"""
# Override the barcode plugin name for each sub-class
PLUGIN_NAME = ""
@property
def name(self):
return self.PLUGIN_NAME
def __init__(self, barcode_data):
"""
Initialize the BarcodePlugin instance
Args:
barcode_data - The raw barcode data
"""
self.data = barcode_data
def getStockItem(self):
"""
Attempt to retrieve a StockItem associated with this barcode.
Default implementation returns None
"""
return None
def getStockItemByHash(self):
"""
Attempt to retrieve a StockItem associated with this barcode,
based on the barcode hash.
"""
try:
item = StockItem.objects.get(uid=self.hash())
return item
except StockItem.DoesNotExist:
return None
def renderStockItem(self, item):
"""
Render a stock item to JSON response
"""
serializer = StockItemSerializer(item, part_detail=True, location_detail=True, supplier_part_detail=True)
return serializer.data
def getStockLocation(self):
"""
Attempt to retrieve a StockLocation associated with this barcode.
Default implementation returns None
"""
return None
def renderStockLocation(self, loc):
"""
Render a stock location to a JSON response
"""
serializer = LocationSerializer(loc)
return serializer.data
def getPart(self):
"""
Attempt to retrieve a Part associated with this barcode.
Default implementation returns None
"""
return None
def renderPart(self, part):
"""
Render a part to JSON response
"""
serializer = PartSerializer(part)
return serializer.data
def hash(self):
"""
Calculate a hash for the barcode data.
This is supposed to uniquely identify the barcode contents,
at least within the bardcode sub-type.
The default implementation simply returns an MD5 hash of the barcode data,
encoded to a string.
This may be sufficient for most applications, but can obviously be overridden
by a subclass.
"""
return hash_barcode(self.data)
def validate(self):
"""
Default implementation returns False
"""
return False
# TODO @matmair remove this with InvenTree 0.7.0
def __init__(self, barcode_data=None):
warnings.warn("using the BarcodePlugin is depreceated", DeprecationWarning)
super().__init__()
self.init(barcode_data)

View File

@ -263,9 +263,9 @@ class BaseInvenTreeSetting(models.Model):
plugin = kwargs.pop('plugin', None)
if plugin:
from plugin import InvenTreePlugin
from plugin import InvenTreePluginBase
if issubclass(plugin.__class__, InvenTreePlugin):
if issubclass(plugin.__class__, InvenTreePluginBase):
plugin = plugin.plugin_config()
kwargs['plugin'] = plugin

View File

@ -29,6 +29,8 @@ from markdownx.models import MarkdownxField
from django_cleanup import cleanup
from djmoney.contrib.exchange.exceptions import MissingRate
from mptt.models import TreeForeignKey, MPTTModel
from mptt.exceptions import InvalidMove
from mptt.managers import TreeManager
@ -1832,9 +1834,14 @@ class Part(MPTTModel):
def get_purchase_price(self, quantity):
currency = currency_code_default()
prices = [convert_money(item.purchase_price, currency).amount for item in self.stock_items.all() if item.purchase_price]
try:
prices = [convert_money(item.purchase_price, currency).amount for item in self.stock_items.all() if item.purchase_price]
except MissingRate:
prices = None
if prices:
return min(prices) * quantity, max(prices) * quantity
return None
@transaction.atomic

View File

@ -20,6 +20,7 @@ from django.contrib import messages
from moneyed import CURRENCIES
from djmoney.contrib.exchange.models import convert_money
from djmoney.contrib.exchange.exceptions import MissingRate
from PIL import Image
@ -425,7 +426,11 @@ class PartDetail(InvenTreeRoleMixin, DetailView):
continue
# convert purchase price to current currency - only one currency in the graph
price = convert_money(stock_item.purchase_price, default_currency)
try:
price = convert_money(stock_item.purchase_price, default_currency)
except MissingRate:
continue
line = {
'price': price.amount,
'qty': stock_item.quantity
@ -487,7 +492,11 @@ class PartDetail(InvenTreeRoleMixin, DetailView):
if None in [sale_item.purchase_price, sale_item.quantity]:
continue
price = convert_money(sale_item.purchase_price, default_currency)
try:
price = convert_money(sale_item.purchase_price, default_currency)
except MissingRate:
continue
line = {
'price': price.amount if price else 0,
'qty': sale_item.quantity,

View File

@ -2,14 +2,18 @@
Utility file to enable simper imports
"""
from .registry import plugin_registry
from .plugin import InvenTreePlugin
from .registry import registry
from .plugin import InvenTreePluginBase
from .integration import IntegrationPluginBase
from .action import ActionPlugin
from .helpers import MixinNotImplementedError, MixinImplementationError
__all__ = [
'ActionPlugin',
'IntegrationPluginBase',
'InvenTreePlugin',
'plugin_registry',
'InvenTreePluginBase',
'registry',
'MixinNotImplementedError',
'MixinImplementationError',
]

View File

@ -2,69 +2,22 @@
"""Class for ActionPlugin"""
import logging
import warnings
import plugin.plugin as plugin
from plugin.builtin.action.mixins import ActionMixin
import plugin.integration
logger = logging.getLogger("inventree")
class ActionPlugin(plugin.InvenTreePlugin):
class ActionPlugin(ActionMixin, plugin.integration.IntegrationPluginBase):
"""
The ActionPlugin class is used to perform custom actions
Legacy action definition - will be replaced
Please use the new Integration Plugin API and the Action mixin
"""
ACTION_NAME = ""
@classmethod
def action_name(cls):
"""
Return the action name for this plugin.
If the ACTION_NAME parameter is empty,
look at the PLUGIN_NAME instead.
"""
action = cls.ACTION_NAME
if not action:
action = cls.PLUGIN_NAME
return action
def __init__(self, user, data=None):
"""
An action plugin takes a user reference, and an optional dataset (dict)
"""
plugin.InvenTreePlugin.__init__(self)
self.user = user
self.data = data
def perform_action(self):
"""
Override this method to perform the action!
"""
def get_result(self):
"""
Result of the action?
"""
# Re-implement this for cutsom actions
return False
def get_info(self):
"""
Extra info? Can be a string / dict / etc
"""
return None
def get_response(self):
"""
Return a response. Default implementation is a simple response
which can be overridden.
"""
return {
"action": self.action_name(),
"result": self.get_result(),
"info": self.get_info(),
}
# TODO @matmair remove this with InvenTree 0.7.0
def __init__(self, user=None, data=None):
warnings.warn("using the ActionPlugin is depreceated", DeprecationWarning)
super().__init__()
self.init(user, data)

View File

@ -4,7 +4,7 @@ from __future__ import unicode_literals
from django.contrib import admin
import plugin.models as models
import plugin.registry as registry
import plugin.registry as pl_registry
def plugin_update(queryset, new_status: bool):
@ -23,7 +23,7 @@ def plugin_update(queryset, new_status: bool):
# Reload plugins if they changed
if apps_changed:
registry.plugin_registry.reload_plugins()
pl_registry.reload_plugins()
@admin.action(description='Activate plugin(s)')

View File

@ -8,7 +8,7 @@ from django.conf import settings
from maintenance_mode.core import set_maintenance_mode
from plugin import plugin_registry
from plugin import registry
logger = logging.getLogger('inventree')
@ -18,14 +18,13 @@ class PluginAppConfig(AppConfig):
name = 'plugin'
def ready(self):
if settings.PLUGINS_ENABLED:
logger.info('Loading InvenTree plugins')
if not plugin_registry.is_loading:
if not registry.is_loading:
# this is the first startup
plugin_registry.collect_plugins()
plugin_registry.load_plugins()
registry.collect_plugins()
registry.load_plugins()
# drop out of maintenance
# makes sure we did not have an error in reloading and maintenance is still active

View File

@ -0,0 +1,68 @@
"""
Plugin mixin classes for action plugin
"""
class ActionMixin:
"""
Mixin that enables custom actions
"""
ACTION_NAME = ""
class MixinMeta:
"""
meta options for this mixin
"""
MIXIN_NAME = 'Actions'
def __init__(self):
super().__init__()
self.add_mixin('action', True, __class__)
def action_name(self):
"""
Action name for this plugin.
If the ACTION_NAME parameter is empty,
uses the PLUGIN_NAME instead.
"""
if self.ACTION_NAME:
return self.ACTION_NAME
return self.name
def init(self, user, data=None):
"""
An action plugin takes a user reference, and an optional dataset (dict)
"""
self.user = user
self.data = data
def perform_action(self):
"""
Override this method to perform the action!
"""
def get_result(self):
"""
Result of the action?
"""
# Re-implement this for cutsom actions
return False
def get_info(self):
"""
Extra info? Can be a string / dict / etc
"""
return None
def get_response(self):
"""
Return a response. Default implementation is a simple response
which can be overridden.
"""
return {
"action": self.action_name(),
"result": self.get_result(),
"info": self.get_info(),
}

View File

@ -0,0 +1,146 @@
"""
Plugin mixin classes for barcode plugin
"""
import string
import hashlib
from stock.models import StockItem
from stock.serializers import StockItemSerializer, LocationSerializer
from part.serializers import PartSerializer
def hash_barcode(barcode_data):
"""
Calculate an MD5 hash of barcode data.
HACK: Remove any 'non printable' characters from the hash,
as it seems browers will remove special control characters...
TODO: Work out a way around this!
"""
barcode_data = str(barcode_data).strip()
printable_chars = filter(lambda x: x in string.printable, barcode_data)
barcode_data = ''.join(list(printable_chars))
hash = hashlib.md5(str(barcode_data).encode())
return str(hash.hexdigest())
class BarcodeMixin:
"""
Mixin that enables barcode handeling
Custom barcode plugins should use and extend this mixin as necessary.
"""
ACTION_NAME = ""
class MixinMeta:
"""
meta options for this mixin
"""
MIXIN_NAME = 'Barcode'
def __init__(self):
super().__init__()
self.add_mixin('barcode', 'has_barcode', __class__)
@property
def has_barcode(self):
"""
Does this plugin have everything needed to process a barcode
"""
return True
def init(self, barcode_data):
"""
Initialize the BarcodePlugin instance
Args:
barcode_data - The raw barcode data
"""
self.data = barcode_data
def getStockItem(self):
"""
Attempt to retrieve a StockItem associated with this barcode.
Default implementation returns None
"""
return None
def getStockItemByHash(self):
"""
Attempt to retrieve a StockItem associated with this barcode,
based on the barcode hash.
"""
try:
item = StockItem.objects.get(uid=self.hash())
return item
except StockItem.DoesNotExist:
return None
def renderStockItem(self, item):
"""
Render a stock item to JSON response
"""
serializer = StockItemSerializer(item, part_detail=True, location_detail=True, supplier_part_detail=True)
return serializer.data
def getStockLocation(self):
"""
Attempt to retrieve a StockLocation associated with this barcode.
Default implementation returns None
"""
return None
def renderStockLocation(self, loc):
"""
Render a stock location to a JSON response
"""
serializer = LocationSerializer(loc)
return serializer.data
def getPart(self):
"""
Attempt to retrieve a Part associated with this barcode.
Default implementation returns None
"""
return None
def renderPart(self, part):
"""
Render a part to JSON response
"""
serializer = PartSerializer(part)
return serializer.data
def hash(self):
"""
Calculate a hash for the barcode data.
This is supposed to uniquely identify the barcode contents,
at least within the bardcode sub-type.
The default implementation simply returns an MD5 hash of the barcode data,
encoded to a string.
This may be sufficient for most applications, but can obviously be overridden
by a subclass.
"""
return hash_barcode(self.data)
def validate(self):
"""
Default implementation returns False
"""
return False

View File

@ -11,6 +11,7 @@ from django.db.utils import OperationalError, ProgrammingError
from plugin.models import PluginConfig, PluginSetting
from plugin.urls import PLUGIN_BASE
from plugin.helpers import MixinImplementationError, MixinNotImplementedError
logger = logging.getLogger('inventree')
@ -86,6 +87,9 @@ class ScheduleMixin:
SCHEDULED_TASKS = {}
class MixinMeta:
"""
Meta options for this mixin
"""
MIXIN_NAME = 'Schedule'
def __init__(self):
@ -97,6 +101,9 @@ class ScheduleMixin:
@property
def has_scheduled_tasks(self):
"""
Are tasks defined for this plugin
"""
return bool(self.scheduled_tasks)
def validate_scheduled_tasks(self):
@ -105,31 +112,37 @@ class ScheduleMixin:
"""
if not self.has_scheduled_tasks:
raise ValueError("SCHEDULED_TASKS not defined")
raise MixinImplementationError("SCHEDULED_TASKS not defined")
for key, task in self.scheduled_tasks.items():
if 'func' not in task:
raise ValueError(f"Task '{key}' is missing 'func' parameter")
raise MixinImplementationError(f"Task '{key}' is missing 'func' parameter")
if 'schedule' not in task:
raise ValueError(f"Task '{key}' is missing 'schedule' parameter")
raise MixinImplementationError(f"Task '{key}' is missing 'schedule' parameter")
schedule = task['schedule'].upper().strip()
if schedule not in self.ALLOWABLE_SCHEDULE_TYPES:
raise ValueError(f"Task '{key}': Schedule '{schedule}' is not a valid option")
raise MixinImplementationError(f"Task '{key}': Schedule '{schedule}' is not a valid option")
# If 'minutes' is selected, it must be provided!
if schedule == 'I' and 'minutes' not in task:
raise ValueError(f"Task '{key}' is missing 'minutes' parameter")
raise MixinImplementationError(f"Task '{key}' is missing 'minutes' parameter")
def get_task_name(self, key):
"""
Task name for key
"""
# Generate a 'unique' task name
slug = self.plugin_slug()
return f"plugin.{slug}.{key}"
def get_task_names(self):
"""
All defined task names
"""
# Returns a list of all task names associated with this plugin instance
return [self.get_task_name(key) for key in self.scheduled_tasks.keys()]
@ -191,10 +204,17 @@ class EventMixin:
"""
def process_event(self, event, *args, **kwargs):
"""
Function to handle events
Must be overridden by plugin
"""
# Default implementation does not do anything
raise NotImplementedError
raise MixinNotImplementedError
class MixinMeta:
"""
Meta options for this mixin
"""
MIXIN_NAME = 'Events'
def __init__(self):
@ -208,6 +228,9 @@ class UrlsMixin:
"""
class MixinMeta:
"""
Meta options for this mixin
"""
MIXIN_NAME = 'URLs'
def __init__(self):
@ -217,28 +240,28 @@ class UrlsMixin:
def setup_urls(self):
"""
setup url endpoints for this plugin
Setup url endpoints for this plugin
"""
return getattr(self, 'URLS', None)
@property
def base_url(self):
"""
returns base url for this plugin
Base url for this plugin
"""
return f'{PLUGIN_BASE}/{self.slug}/'
@property
def internal_name(self):
"""
returns the internal url pattern name
Internal url pattern name
"""
return f'plugin:{self.slug}:'
@property
def urlpatterns(self):
"""
returns the urlpatterns for this plugin
Urlpatterns for this plugin
"""
if self.has_urls:
return url(f'^{self.slug}/', include((self.urls, self.slug)), name=self.slug)
@ -247,7 +270,7 @@ class UrlsMixin:
@property
def has_urls(self):
"""
does this plugin use custom urls
Does this plugin use custom urls
"""
return bool(self.urls)
@ -262,7 +285,7 @@ class NavigationMixin:
class MixinMeta:
"""
meta options for this mixin
Meta options for this mixin
"""
MIXIN_NAME = 'Navigation Links'
@ -273,26 +296,28 @@ class NavigationMixin:
def setup_navigation(self):
"""
setup navigation links for this plugin
Setup navigation links for this plugin
"""
nav_links = getattr(self, 'NAVIGATION', None)
if nav_links:
# check if needed values are configured
for link in nav_links:
if False in [a in link for a in ('link', 'name', )]:
raise NotImplementedError('Wrong Link definition', link)
raise MixinNotImplementedError('Wrong Link definition', link)
return nav_links
@property
def has_naviation(self):
"""
does this plugin define navigation elements
Does this plugin define navigation elements
"""
return bool(self.navigation)
@property
def navigation_name(self):
"""name for navigation tab"""
"""
Name for navigation tab
"""
name = getattr(self, 'NAVIGATION_TAB_NAME', None)
if not name:
name = self.human_name
@ -300,7 +325,9 @@ class NavigationMixin:
@property
def navigation_icon(self):
"""icon for navigation tab"""
"""
Icon-name for navigation tab
"""
return getattr(self, 'NAVIGATION_TAB_ICON', "fas fa-question")
@ -310,7 +337,9 @@ class AppMixin:
"""
class MixinMeta:
"""meta options for this mixin"""
"""m
Mta options for this mixin
"""
MIXIN_NAME = 'App registration'
def __init__(self):
@ -320,7 +349,7 @@ class AppMixin:
@property
def has_app(self):
"""
this plugin is always an app with this plugin
This plugin is always an app with this plugin
"""
return True

View File

@ -17,7 +17,7 @@ from common.models import InvenTreeSetting
from InvenTree.ready import canAppAccessDatabase
from InvenTree.tasks import offload_task
from plugin.registry import plugin_registry
from plugin.registry import registry
logger = logging.getLogger('inventree')
@ -64,7 +64,7 @@ def register_event(event, *args, **kwargs):
with transaction.atomic():
for slug, plugin in plugin_registry.plugins.items():
for slug, plugin in registry.plugins.items():
if plugin.mixin_enabled('events'):
@ -95,7 +95,7 @@ def process_event(plugin_slug, event, *args, **kwargs):
logger.info(f"Plugin '{plugin_slug}' is processing triggered event '{event}'")
plugin = plugin_registry.plugins[plugin_slug]
plugin = registry.plugins[plugin_slug]
plugin.process_event(event, *args, **kwargs)

View File

@ -1,26 +1,23 @@
"""Helpers for plugin app"""
"""
Helpers for plugin app
"""
import os
import subprocess
import pathlib
import sysconfig
import traceback
import inspect
import pkgutil
from django.conf import settings
from django.core.exceptions import AppRegistryNotReady
# region logging / errors
def log_plugin_error(error, reference: str = 'general'):
from plugin import plugin_registry
# make sure the registry is set up
if reference not in plugin_registry.errors:
plugin_registry.errors[reference] = []
# add error to stack
plugin_registry.errors[reference].append(error)
class IntegrationPluginError(Exception):
"""
Error that encapsulates another error and adds the path / reference of the raising plugin
"""
def __init__(self, path, message):
self.path = path
self.message = message
@ -29,7 +26,39 @@ class IntegrationPluginError(Exception):
return self.message
def get_plugin_error(error, do_raise: bool = False, do_log: bool = False, log_name: str = ''):
class MixinImplementationError(ValueError):
"""
Error if mixin was implemented wrong in plugin
Mostly raised if constant is missing
"""
pass
class MixinNotImplementedError(NotImplementedError):
"""
Error if necessary mixin function was not overwritten
"""
pass
def log_error(error, reference: str = 'general'):
"""
Log an plugin error
"""
from plugin import registry
# make sure the registry is set up
if reference not in registry.errors:
registry.errors[reference] = []
# add error to stack
registry.errors[reference].append(error)
def handle_error(error, do_raise: bool = True, do_log: bool = True, do_return: bool = False, log_name: str = ''):
"""
Handles an error and casts it as an IntegrationPluginError
"""
package_path = traceback.extract_tb(error.__traceback__)[-1].filename
install_path = sysconfig.get_paths()["purelib"]
try:
@ -53,18 +82,23 @@ def get_plugin_error(error, do_raise: bool = False, do_log: bool = False, log_na
log_kwargs = {}
if log_name:
log_kwargs['reference'] = log_name
log_plugin_error({package_name: str(error)}, **log_kwargs)
log_error({package_name: str(error)}, **log_kwargs)
new_error = IntegrationPluginError(package_name, str(error))
if do_raise:
raise IntegrationPluginError(package_name, str(error))
return package_name, str(error)
if do_return:
return new_error
# endregion
# region git-helpers
def get_git_log(path):
"""get dict with info of the last commit to file named in path"""
"""
Get dict with info of the last commit to file named in path
"""
path = path.replace(os.path.dirname(settings.BASE_DIR), '')[1:]
command = ['git', 'log', '-n', '1', "--pretty=format:'%H%n%aN%n%aE%n%aI%n%f%n%G?%n%GK'", '--follow', '--', path]
try:
@ -79,9 +113,13 @@ def get_git_log(path):
class GitStatus:
"""class for resolving git gpg singing state"""
"""
Class for resolving git gpg singing state
"""
class Definition:
"""definition of a git gpg sing state"""
"""
Definition of a git gpg sing state
"""
key: str = 'N'
status: int = 2
msg: str = ''
@ -100,3 +138,56 @@ class GitStatus:
R = Definition(key='R', status=2, msg='good signature, revoked key',)
E = Definition(key='E', status=1, msg='cannot be checked',)
# endregion
# region plugin finders
def get_modules(pkg):
"""get all modules in a package"""
context = {}
for loader, name, ispkg in pkgutil.walk_packages(pkg.__path__):
try:
module = loader.find_module(name).load_module(name)
pkg_names = getattr(module, '__all__', None)
for k, v in vars(module).items():
if not k.startswith('_') and (pkg_names is None or k in pkg_names):
context[k] = v
context[name] = module
except AppRegistryNotReady:
pass
except Exception as error:
# this 'protects' against malformed plugin modules by more or less silently failing
# log to stack
log_error({name: str(error)}, 'discovery')
return [v for k, v in context.items()]
def get_classes(module):
"""get all classes in a given module"""
return inspect.getmembers(module, inspect.isclass)
def get_plugins(pkg, baseclass):
"""
Return a list of all modules under a given package.
- Modules must be a subclass of the provided 'baseclass'
- Modules must have a non-empty PLUGIN_NAME parameter
"""
plugins = []
modules = get_modules(pkg)
# Iterate through each module in the package
for mod in modules:
# Iterate through each class in the module
for item in get_classes(mod):
plugin = item[1]
if issubclass(plugin, baseclass) and plugin.PLUGIN_NAME:
plugins.append(plugin)
return plugins
# endregion

View File

@ -1,5 +1,7 @@
# -*- coding: utf-8 -*-
"""class for IntegrationPluginBase and Mixins for it"""
"""
Class for IntegrationPluginBase and Mixin Base
"""
import logging
import os
@ -11,7 +13,7 @@ from django.urls.base import reverse
from django.conf import settings
from django.utils.translation import ugettext_lazy as _
import plugin.plugin as plugin
import plugin.plugin as plugin_base
from plugin.helpers import get_git_log, GitStatus
@ -20,7 +22,7 @@ logger = logging.getLogger("inventree")
class MixinBase:
"""
General base for mixins
Base set of mixin functions and mechanisms
"""
def __init__(self) -> None:
@ -65,7 +67,7 @@ class MixinBase:
return mixins
class IntegrationPluginBase(MixinBase, plugin.InvenTreePlugin):
class IntegrationPluginBase(MixinBase, plugin_base.InvenTreePluginBase):
"""
The IntegrationPluginBase class is used to integrate with 3rd party software
"""
@ -83,32 +85,42 @@ class IntegrationPluginBase(MixinBase, plugin.InvenTreePlugin):
self.def_path = inspect.getfile(self.__class__)
self.path = os.path.dirname(self.def_path)
self.set_package()
self.define_package()
@property
def _is_package(self):
"""
Is the plugin delivered as a package
"""
return getattr(self, 'is_package', False)
# region properties
@property
def slug(self):
"""
Slug of plugin
"""
return self.plugin_slug()
@property
def name(self):
"""
Name of plugin
"""
return self.plugin_name()
@property
def human_name(self):
"""human readable name for labels etc."""
human_name = getattr(self, 'PLUGIN_TITLE', None)
if not human_name:
human_name = self.plugin_name()
return human_name
"""
Human readable name of plugin
"""
return self.plugin_title()
@property
def description(self):
"""description of plugin"""
"""
Description of plugin
"""
description = getattr(self, 'DESCRIPTION', None)
if not description:
description = self.plugin_name()
@ -116,7 +128,9 @@ class IntegrationPluginBase(MixinBase, plugin.InvenTreePlugin):
@property
def author(self):
"""returns author of plugin - either from plugin settings or git"""
"""
Author of plugin - either from plugin settings or git
"""
author = getattr(self, 'AUTHOR', None)
if not author:
author = self.package.get('author')
@ -126,7 +140,9 @@ class IntegrationPluginBase(MixinBase, plugin.InvenTreePlugin):
@property
def pub_date(self):
"""returns publishing date of plugin - either from plugin settings or git"""
"""
Publishing date of plugin - either from plugin settings or git
"""
pub_date = getattr(self, 'PUBLISH_DATE', None)
if not pub_date:
pub_date = self.package.get('date')
@ -138,42 +154,56 @@ class IntegrationPluginBase(MixinBase, plugin.InvenTreePlugin):
@property
def version(self):
"""returns version of plugin"""
"""
Version of plugin
"""
version = getattr(self, 'VERSION', None)
return version
@property
def website(self):
"""returns website of plugin"""
"""
Website of plugin - if set else None
"""
website = getattr(self, 'WEBSITE', None)
return website
@property
def license(self):
"""returns license of plugin"""
"""
License of plugin
"""
license = getattr(self, 'LICENSE', None)
return license
# endregion
@property
def package_path(self):
"""returns the path to the plugin"""
"""
Path to the plugin
"""
if self._is_package:
return self.__module__
return pathlib.Path(self.def_path).relative_to(settings.BASE_DIR)
@property
def settings_url(self):
"""returns url to the settings panel"""
"""
URL to the settings panel for this plugin
"""
return f'{reverse("settings")}#select-plugin-{self.slug}'
# region mixins
def mixin(self, key):
"""check if mixin is registered"""
"""
Check if mixin is registered
"""
return key in self._mixins
def mixin_enabled(self, key):
"""check if mixin is enabled and ready"""
"""
Check if mixin is registered, enabled and ready
"""
if self.mixin(key):
fnc_name = self._mixins.get(key)
@ -186,17 +216,23 @@ class IntegrationPluginBase(MixinBase, plugin.InvenTreePlugin):
# endregion
# region package info
def get_package_commit(self):
"""get last git commit for plugin"""
def _get_package_commit(self):
"""
Get last git commit for the plugin
"""
return get_git_log(self.def_path)
def get_package_metadata(self):
"""get package metadata for plugin"""
def _get_package_metadata(self):
"""
Get package metadata for plugin
"""
return {}
def set_package(self):
"""add packaging info of the plugins into plugins context"""
package = self.get_package_metadata() if self._is_package else self.get_package_commit()
def define_package(self):
"""
Add package info of the plugin into plugins context
"""
package = self._get_package_metadata() if self._is_package else self._get_package_commit()
# process date
if package.get('date'):

View File

@ -4,7 +4,7 @@ load templates for loaded plugins
from django.template.loaders.filesystem import Loader as FilesystemLoader
from pathlib import Path
from plugin import plugin_registry
from plugin import registry
class PluginTemplateLoader(FilesystemLoader):
@ -12,7 +12,7 @@ class PluginTemplateLoader(FilesystemLoader):
def get_dirs(self):
dirname = 'templates'
template_dirs = []
for plugin in plugin_registry.plugins.values():
for plugin in registry.plugins.values():
new_path = Path(plugin.path) / dirname
if Path(new_path).is_dir():
template_dirs.append(new_path)

View File

@ -3,6 +3,8 @@ Utility class to enable simpler imports
"""
from ..builtin.integration.mixins import APICallMixin, AppMixin, SettingsMixin, EventMixin, ScheduleMixin, UrlsMixin, NavigationMixin
from ..builtin.action.mixins import ActionMixin
from ..builtin.barcode.mixins import BarcodeMixin
__all__ = [
'APICallMixin',
@ -12,4 +14,6 @@ __all__ = [
'ScheduleMixin',
'SettingsMixin',
'UrlsMixin',
'ActionMixin',
'BarcodeMixin',
]

View File

@ -10,7 +10,7 @@ from django.db import models
import common.models
from plugin import InvenTreePlugin, plugin_registry
from plugin import InvenTreePluginBase, registry
class PluginConfig(models.Model):
@ -72,7 +72,7 @@ class PluginConfig(models.Model):
self.__org_active = self.active
# append settings from registry
self.plugin = plugin_registry.plugins.get(self.key, None)
self.plugin = registry.plugins.get(self.key, None)
def get_plugin_meta(name):
if self.plugin:
@ -95,10 +95,10 @@ class PluginConfig(models.Model):
if not reload:
if self.active is False and self.__org_active is True:
plugin_registry.reload_plugins()
registry.reload_plugins()
elif self.active is True and self.__org_active is False:
plugin_registry.reload_plugins()
registry.reload_plugins()
return ret
@ -164,10 +164,10 @@ class PluginSetting(common.models.BaseInvenTreeSetting):
if plugin:
if issubclass(plugin.__class__, InvenTreePlugin):
if issubclass(plugin.__class__, InvenTreePluginBase):
plugin = plugin.plugin_config()
kwargs['settings'] = plugin_registry.mixins_settings.get(plugin.key, {})
kwargs['settings'] = registry.mixins_settings.get(plugin.key, {})
return super().get_setting_definition(key, **kwargs)
@ -182,7 +182,7 @@ class PluginSetting(common.models.BaseInvenTreeSetting):
plugin = kwargs.get('plugin', None)
if plugin:
if issubclass(plugin.__class__, InvenTreePlugin):
if issubclass(plugin.__class__, InvenTreePluginBase):
plugin = plugin.plugin_config()
filters['plugin'] = plugin

View File

@ -2,14 +2,16 @@
"""
Base Class for InvenTree plugins
"""
import warnings
from django.db.utils import OperationalError, ProgrammingError
from django.utils.text import slugify
class InvenTreePlugin():
class InvenTreePluginBase():
"""
Base class for a plugin
DO NOT USE THIS DIRECTLY, USE plugin.IntegrationPluginBase
"""
def __init__(self):
@ -24,11 +26,15 @@ class InvenTreePlugin():
def plugin_name(self):
"""
Return the name of this plugin plugin
Name of plugin
"""
return self.PLUGIN_NAME
def plugin_slug(self):
"""
Slug of plugin
If not set plugin name slugified
"""
slug = getattr(self, 'PLUGIN_SLUG', None)
@ -38,6 +44,9 @@ class InvenTreePlugin():
return slugify(slug.lower())
def plugin_title(self):
"""
Title of plugin
"""
if self.PLUGIN_TITLE:
return self.PLUGIN_TITLE
@ -75,3 +84,13 @@ class InvenTreePlugin():
return cfg.active
else:
return False
# TODO @matmair remove after InvenTree 0.7.0 release
class InvenTreePlugin(InvenTreePluginBase):
"""
This is here for leagcy reasons and will be removed in the next major release
"""
def __init__(self):
warnings.warn("Using the InvenTreePlugin is depreceated", DeprecationWarning)
super().__init__()

View File

@ -1,114 +0,0 @@
# -*- coding: utf-8 -*-
"""general functions for plugin handeling"""
import inspect
import importlib
import pkgutil
import logging
from django.core.exceptions import AppRegistryNotReady
# Action plugins
import plugin.builtin.action as action
from plugin.action import ActionPlugin
logger = logging.getLogger("inventree")
def iter_namespace(pkg):
"""get all modules in a package"""
return pkgutil.iter_modules(pkg.__path__, pkg.__name__ + ".")
def get_modules(pkg, recursive: bool = False):
"""get all modules in a package"""
from plugin.helpers import log_plugin_error
if not recursive:
return [importlib.import_module(name) for finder, name, ispkg in iter_namespace(pkg)]
context = {}
for loader, name, ispkg in pkgutil.walk_packages(pkg.__path__):
try:
module = loader.find_module(name).load_module(name)
pkg_names = getattr(module, '__all__', None)
for k, v in vars(module).items():
if not k.startswith('_') and (pkg_names is None or k in pkg_names):
context[k] = v
context[name] = module
except AppRegistryNotReady:
pass
except Exception as error:
# this 'protects' against malformed plugin modules by more or less silently failing
# log to stack
log_plugin_error({name: str(error)}, 'discovery')
return [v for k, v in context.items()]
def get_classes(module):
"""get all classes in a given module"""
return inspect.getmembers(module, inspect.isclass)
def get_plugins(pkg, baseclass, recursive: bool = False):
"""
Return a list of all modules under a given package.
- Modules must be a subclass of the provided 'baseclass'
- Modules must have a non-empty PLUGIN_NAME parameter
"""
plugins = []
modules = get_modules(pkg, recursive)
# Iterate through each module in the package
for mod in modules:
# Iterate through each class in the module
for item in get_classes(mod):
plugin = item[1]
if issubclass(plugin, baseclass) and plugin.PLUGIN_NAME:
plugins.append(plugin)
return plugins
def load_plugins(name: str, cls, module):
"""general function to load a plugin class
:param name: name of the plugin for logs
:type name: str
:param module: module from which the plugins should be loaded
:return: class of the to-be-loaded plugin
"""
logger.debug("Loading %s plugins", name)
plugins = get_plugins(module, cls)
if len(plugins) > 0:
logger.info("Discovered %i %s plugins:", len(plugins), name)
for plugin in plugins:
logger.debug(" - %s", plugin.PLUGIN_NAME)
return plugins
def load_action_plugins():
"""
Return a list of all registered action plugins
"""
return load_plugins('action', ActionPlugin, action)
def load_barcode_plugins():
"""
Return a list of all registered barcode plugins
"""
from barcodes import plugins as BarcodePlugins
from barcodes.barcode import BarcodePlugin
return load_plugins('barcode', BarcodePlugin, BarcodePlugins)

View File

@ -28,9 +28,8 @@ except:
from maintenance_mode.core import maintenance_mode_on
from maintenance_mode.core import get_maintenance_mode, set_maintenance_mode
from plugin import plugins as inventree_plugins
from .integration import IntegrationPluginBase
from .helpers import get_plugin_error, IntegrationPluginError
from .helpers import handle_error, log_error, get_plugins, IntegrationPluginError
logger = logging.getLogger('inventree')
@ -60,18 +59,16 @@ class PluginsRegistry:
# mixins
self.mixins_settings = {}
# region public plugin functions
# region public functions
# region loading / unloading
def load_plugins(self):
"""
Load and activate all IntegrationPlugins
"""
if not settings.PLUGINS_ENABLED:
# Plugins not enabled, do nothing
return
from plugin.helpers import log_plugin_error
logger.info('Start loading plugins')
# Set maintanace mode
@ -95,7 +92,7 @@ class PluginsRegistry:
break
except IntegrationPluginError as error:
logger.error(f'[PLUGIN] Encountered an error with {error.path}:\n{error.message}')
log_plugin_error({error.path: error.message}, 'load')
log_error({error.path: error.message}, 'load')
blocked_plugin = error.path # we will not try to load this app again
# Initialize apps without any integration plugins
@ -179,7 +176,7 @@ class PluginsRegistry:
# Collect plugins from paths
for plugin in settings.PLUGIN_DIRS:
modules = inventree_plugins.get_plugins(importlib.import_module(plugin), IntegrationPluginBase, True)
modules = get_plugins(importlib.import_module(plugin), IntegrationPluginBase)
if modules:
[self.plugin_modules.append(item) for item in modules]
@ -192,12 +189,29 @@ class PluginsRegistry:
plugin.is_package = True
self.plugin_modules.append(plugin)
except Exception as error:
get_plugin_error(error, do_log=True, log_name='discovery')
handle_error(error, do_raise=False, log_name='discovery')
# Log collected plugins
logger.info(f'Collected {len(self.plugin_modules)} plugins!')
logger.info(", ".join([a.__module__ for a in self.plugin_modules]))
# endregion
# region registry functions
def with_mixin(self, mixin: str):
"""
Returns reference to all plugins that have a specified mixin enabled
"""
result = []
for plugin in self.plugins.values():
if plugin.mixin_enabled(mixin):
result.append(plugin)
return result
# endregion
# endregion
# region general internal loading /activating / deactivating / deloading
def _init_plugins(self, disabled=None):
"""
Initialise all found plugins
@ -254,7 +268,7 @@ class PluginsRegistry:
plugin = plugin()
except Exception as error:
# log error and raise it -> disable plugin
get_plugin_error(error, do_raise=True, do_log=True, log_name='init')
handle_error(error, log_name='init')
logger.info(f'Loaded integration plugin {plugin.slug}')
plugin.is_package = was_packaged
@ -290,7 +304,9 @@ class PluginsRegistry:
self.deactivate_integration_app()
self.deactivate_integration_schedule()
self.deactivate_integration_settings()
# endregion
# region mixin specific loading ...
def activate_integration_settings(self, plugins):
logger.info('Activating plugin settings')
@ -536,7 +552,8 @@ class PluginsRegistry:
cmd(*args, **kwargs)
return True, []
except Exception as error:
get_plugin_error(error, do_raise=True)
handle_error(error)
# endregion
plugin_registry = PluginsRegistry()
registry = PluginsRegistry()

View File

@ -2,7 +2,7 @@
from django.test import TestCase
from plugin import plugin_registry
from plugin import registry
class SampleApiCallerPluginTests(TestCase):
@ -11,8 +11,8 @@ class SampleApiCallerPluginTests(TestCase):
def test_return(self):
"""check if the external api call works"""
# The plugin should be defined
self.assertIn('sample-api-caller', plugin_registry.plugins)
plg = plugin_registry.plugins['sample-api-caller']
self.assertIn('sample-api-caller', registry.plugins)
plg = registry.plugins['sample-api-caller']
self.assertTrue(plg)
# do an api call

View File

@ -7,7 +7,7 @@ from django import template
from django.urls import reverse
from common.models import InvenTreeSetting
from plugin import plugin_registry
from plugin import registry
register = template.Library()
@ -15,31 +15,41 @@ register = template.Library()
@register.simple_tag()
def plugin_list(*args, **kwargs):
""" Return a list of all installed integration plugins """
return plugin_registry.plugins
"""
List of all installed integration plugins
"""
return registry.plugins
@register.simple_tag()
def inactive_plugin_list(*args, **kwargs):
""" Return a list of all inactive integration plugins """
return plugin_registry.plugins_inactive
"""
List of all inactive integration plugins
"""
return registry.plugins_inactive
@register.simple_tag()
def plugin_settings(plugin, *args, **kwargs):
""" Return a list of all custom settings for a plugin """
return plugin_registry.mixins_settings.get(plugin)
"""
List of all settings for the plugin
"""
return registry.mixins_settings.get(plugin)
@register.simple_tag()
def mixin_enabled(plugin, key, *args, **kwargs):
""" Return if the mixin is existant and configured in the plugin """
"""
Is the mixin registerd and configured in the plugin?
"""
return plugin.mixin_enabled(key)
@register.simple_tag()
def navigation_enabled(*args, **kwargs):
"""Return if plugin navigation is enabled"""
"""
Is plugin navigation enabled?
"""
if djangosettings.PLUGIN_TESTING:
return True
return InvenTreeSetting.get_setting('ENABLE_PLUGINS_NAVIGATION')
@ -47,7 +57,10 @@ def navigation_enabled(*args, **kwargs):
@register.simple_tag()
def safe_url(view_name, *args, **kwargs):
""" safe lookup for urls """
"""
Safe lookup fnc for URLs
Returns None if not found
"""
try:
return reverse(view_name, args=args, kwargs=kwargs)
except:
@ -56,5 +69,7 @@ def safe_url(view_name, *args, **kwargs):
@register.simple_tag()
def plugin_errors(*args, **kwargs):
"""Return all plugin errors"""
return plugin_registry.errors
"""
All plugin errors in the current session
"""
return registry.errors

View File

@ -64,14 +64,14 @@ class PluginDetailAPITest(InvenTreeAPITestCase):
Test the PluginConfig action commands
"""
from plugin.models import PluginConfig
from plugin import plugin_registry
from plugin import registry
url = reverse('admin:plugin_pluginconfig_changelist')
fixtures = PluginConfig.objects.all()
# check if plugins were registered -> in some test setups the startup has no db access
if not fixtures:
plugin_registry.reload_plugins()
registry.reload_plugins()
fixtures = PluginConfig.objects.all()
print([str(a) for a in fixtures])

View File

@ -4,20 +4,18 @@ Unit tests for plugins
from django.test import TestCase
import plugin.plugin
import plugin.integration
from plugin.samples.integration.sample import SampleIntegrationPlugin
from plugin.samples.integration.another_sample import WrongIntegrationPlugin, NoIntegrationPlugin
import plugin.templatetags.plugin_extras as plugin_tags
from plugin import plugin_registry
from plugin import registry, InvenTreePluginBase
class InvenTreePluginTests(TestCase):
""" Tests for InvenTreePlugin """
def setUp(self):
self.plugin = plugin.plugin.InvenTreePlugin()
self.plugin = InvenTreePluginBase()
class NamedPlugin(plugin.plugin.InvenTreePlugin):
class NamedPlugin(InvenTreePluginBase):
"""a named plugin"""
PLUGIN_NAME = 'abc123'
@ -34,20 +32,6 @@ class InvenTreePluginTests(TestCase):
self.assertEqual(self.named_plugin.plugin_name(), 'abc123')
class PluginIntegrationTests(TestCase):
""" Tests for general plugin functions """
def test_plugin_loading(self):
"""check if plugins load as expected"""
# plugin_names_barcode = [a().plugin_name() for a in load_barcode_plugins()] # TODO refactor barcode plugin to support standard loading
# plugin_names_action = [a().plugin_name() for a in load_action_plugins()] # TODO refactor action plugin to support standard loading
# self.assertEqual(plugin_names_action, '')
# self.assertEqual(plugin_names_barcode, '')
# TODO remove test once loading is moved
class PluginTagTests(TestCase):
""" Tests for the plugin extras """
@ -58,17 +42,17 @@ class PluginTagTests(TestCase):
def test_tag_plugin_list(self):
"""test that all plugins are listed"""
self.assertEqual(plugin_tags.plugin_list(), plugin_registry.plugins)
self.assertEqual(plugin_tags.plugin_list(), registry.plugins)
def test_tag_incative_plugin_list(self):
"""test that all inactive plugins are listed"""
self.assertEqual(plugin_tags.inactive_plugin_list(), plugin_registry.plugins_inactive)
self.assertEqual(plugin_tags.inactive_plugin_list(), registry.plugins_inactive)
def test_tag_plugin_settings(self):
"""check all plugins are listed"""
self.assertEqual(
plugin_tags.plugin_settings(self.sample),
plugin_registry.mixins_settings.get(self.sample)
registry.mixins_settings.get(self.sample)
)
def test_tag_mixin_enabled(self):
@ -90,4 +74,4 @@ class PluginTagTests(TestCase):
def test_tag_plugin_errors(self):
"""test that all errors are listed"""
self.assertEqual(plugin_tags.plugin_errors(), plugin_registry.errors)
self.assertEqual(plugin_tags.plugin_errors(), registry.errors)

View File

@ -4,7 +4,7 @@ URL lookup for plugin app
from django.conf.urls import url, include
from plugin import plugin_registry
from plugin import registry
PLUGIN_BASE = 'plugin' # Constant for links
@ -17,7 +17,7 @@ def get_plugin_urls():
urls = []
for plugin in plugin_registry.plugins.values():
for plugin in registry.plugins.values():
if plugin.mixin_enabled('urls'):
urls.append(plugin.urlpatterns)

View File

@ -0,0 +1,19 @@
{% extends "base.html" %}
{% load i18n %}
{% block page_title %}
InvenTree | {% trans "Internal Server Error" %}
{% endblock %}
{% block content %}
<div class='container-fluid'>
<h3>{% trans "Internal Server Error" %}</h3>
<div class='alert alert-danger alert-block'>
{% trans "The InvenTree server raised an internal error" %}<br>
{% trans "Refer to the error log in the admin interface for further details" %}
</div>
</div>
{% endblock %}

View File

@ -929,6 +929,7 @@ function loadPartPurchaseOrderTable(table, part_id, options={}) {
{
field: 'actions',
title: '',
switchable: false,
formatter: function(value, row) {
if (row.received >= row.quantity) {