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

View File

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

View File

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

View File

@ -1,139 +1,20 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import warnings
import string import plugin.builtin.barcode.mixins as mixin
import hashlib import plugin.integration
import logging
from stock.models import StockItem hash_barcode = mixin.hash_barcode
from stock.serializers import StockItemSerializer, LocationSerializer
from part.serializers import PartSerializer
logger = logging.getLogger('inventree') class BarcodePlugin(mixin.BarcodeMixin, plugin.integration.IntegrationPluginBase):
def hash_barcode(barcode_data):
""" """
Calculate an MD5 hash of barcode data. Legacy barcode plugin definition - will be replaced
Please use the new Integration Plugin API and the BarcodeMixin
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!
""" """
# TODO @matmair remove this with InvenTree 0.7.0
barcode_data = str(barcode_data).strip() def __init__(self, barcode_data=None):
warnings.warn("using the BarcodePlugin is depreceated", DeprecationWarning)
printable_chars = filter(lambda x: x in string.printable, barcode_data) super().__init__()
self.init(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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,69 +2,22 @@
"""Class for ActionPlugin""" """Class for ActionPlugin"""
import logging import logging
import warnings
import plugin.plugin as plugin from plugin.builtin.action.mixins import ActionMixin
import plugin.integration
logger = logging.getLogger("inventree") 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
""" """
# TODO @matmair remove this with InvenTree 0.7.0
ACTION_NAME = "" def __init__(self, user=None, data=None):
warnings.warn("using the ActionPlugin is depreceated", DeprecationWarning)
@classmethod super().__init__()
def action_name(cls): self.init(user, data)
"""
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(),
}

View File

@ -4,7 +4,7 @@ from __future__ import unicode_literals
from django.contrib import admin from django.contrib import admin
import plugin.models as models import plugin.models as models
import plugin.registry as registry import plugin.registry as pl_registry
def plugin_update(queryset, new_status: bool): def plugin_update(queryset, new_status: bool):
@ -23,7 +23,7 @@ def plugin_update(queryset, new_status: bool):
# Reload plugins if they changed # Reload plugins if they changed
if apps_changed: if apps_changed:
registry.plugin_registry.reload_plugins() pl_registry.reload_plugins()
@admin.action(description='Activate plugin(s)') @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 maintenance_mode.core import set_maintenance_mode
from plugin import plugin_registry from plugin import registry
logger = logging.getLogger('inventree') logger = logging.getLogger('inventree')
@ -18,14 +18,13 @@ class PluginAppConfig(AppConfig):
name = 'plugin' name = 'plugin'
def ready(self): def ready(self):
if settings.PLUGINS_ENABLED: if settings.PLUGINS_ENABLED:
logger.info('Loading InvenTree plugins') logger.info('Loading InvenTree plugins')
if not plugin_registry.is_loading: if not registry.is_loading:
# this is the first startup # this is the first startup
plugin_registry.collect_plugins() registry.collect_plugins()
plugin_registry.load_plugins() registry.load_plugins()
# drop out of maintenance # drop out of maintenance
# makes sure we did not have an error in reloading and maintenance is still active # 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.models import PluginConfig, PluginSetting
from plugin.urls import PLUGIN_BASE from plugin.urls import PLUGIN_BASE
from plugin.helpers import MixinImplementationError, MixinNotImplementedError
logger = logging.getLogger('inventree') logger = logging.getLogger('inventree')
@ -86,6 +87,9 @@ class ScheduleMixin:
SCHEDULED_TASKS = {} SCHEDULED_TASKS = {}
class MixinMeta: class MixinMeta:
"""
Meta options for this mixin
"""
MIXIN_NAME = 'Schedule' MIXIN_NAME = 'Schedule'
def __init__(self): def __init__(self):
@ -97,6 +101,9 @@ class ScheduleMixin:
@property @property
def has_scheduled_tasks(self): def has_scheduled_tasks(self):
"""
Are tasks defined for this plugin
"""
return bool(self.scheduled_tasks) return bool(self.scheduled_tasks)
def validate_scheduled_tasks(self): def validate_scheduled_tasks(self):
@ -105,31 +112,37 @@ class ScheduleMixin:
""" """
if not self.has_scheduled_tasks: 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(): for key, task in self.scheduled_tasks.items():
if 'func' not in task: 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: 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() schedule = task['schedule'].upper().strip()
if schedule not in self.ALLOWABLE_SCHEDULE_TYPES: 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 'minutes' is selected, it must be provided!
if schedule == 'I' and 'minutes' not in task: 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): def get_task_name(self, key):
"""
Task name for key
"""
# Generate a 'unique' task name # Generate a 'unique' task name
slug = self.plugin_slug() slug = self.plugin_slug()
return f"plugin.{slug}.{key}" return f"plugin.{slug}.{key}"
def get_task_names(self): def get_task_names(self):
"""
All defined task names
"""
# Returns a list of all task names associated with this plugin instance # 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()] 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): def process_event(self, event, *args, **kwargs):
"""
Function to handle events
Must be overridden by plugin
"""
# Default implementation does not do anything # Default implementation does not do anything
raise NotImplementedError raise MixinNotImplementedError
class MixinMeta: class MixinMeta:
"""
Meta options for this mixin
"""
MIXIN_NAME = 'Events' MIXIN_NAME = 'Events'
def __init__(self): def __init__(self):
@ -208,6 +228,9 @@ class UrlsMixin:
""" """
class MixinMeta: class MixinMeta:
"""
Meta options for this mixin
"""
MIXIN_NAME = 'URLs' MIXIN_NAME = 'URLs'
def __init__(self): def __init__(self):
@ -217,28 +240,28 @@ class UrlsMixin:
def setup_urls(self): def setup_urls(self):
""" """
setup url endpoints for this plugin Setup url endpoints for this plugin
""" """
return getattr(self, 'URLS', None) return getattr(self, 'URLS', None)
@property @property
def base_url(self): def base_url(self):
""" """
returns base url for this plugin Base url for this plugin
""" """
return f'{PLUGIN_BASE}/{self.slug}/' return f'{PLUGIN_BASE}/{self.slug}/'
@property @property
def internal_name(self): def internal_name(self):
""" """
returns the internal url pattern name Internal url pattern name
""" """
return f'plugin:{self.slug}:' return f'plugin:{self.slug}:'
@property @property
def urlpatterns(self): def urlpatterns(self):
""" """
returns the urlpatterns for this plugin Urlpatterns for this plugin
""" """
if self.has_urls: if self.has_urls:
return url(f'^{self.slug}/', include((self.urls, self.slug)), name=self.slug) return url(f'^{self.slug}/', include((self.urls, self.slug)), name=self.slug)
@ -247,7 +270,7 @@ class UrlsMixin:
@property @property
def has_urls(self): def has_urls(self):
""" """
does this plugin use custom urls Does this plugin use custom urls
""" """
return bool(self.urls) return bool(self.urls)
@ -262,7 +285,7 @@ class NavigationMixin:
class MixinMeta: class MixinMeta:
""" """
meta options for this mixin Meta options for this mixin
""" """
MIXIN_NAME = 'Navigation Links' MIXIN_NAME = 'Navigation Links'
@ -273,26 +296,28 @@ class NavigationMixin:
def setup_navigation(self): def setup_navigation(self):
""" """
setup navigation links for this plugin Setup navigation links for this plugin
""" """
nav_links = getattr(self, 'NAVIGATION', None) nav_links = getattr(self, 'NAVIGATION', None)
if nav_links: if nav_links:
# check if needed values are configured # check if needed values are configured
for link in nav_links: for link in nav_links:
if False in [a in link for a in ('link', 'name', )]: 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 return nav_links
@property @property
def has_naviation(self): def has_naviation(self):
""" """
does this plugin define navigation elements Does this plugin define navigation elements
""" """
return bool(self.navigation) return bool(self.navigation)
@property @property
def navigation_name(self): def navigation_name(self):
"""name for navigation tab""" """
Name for navigation tab
"""
name = getattr(self, 'NAVIGATION_TAB_NAME', None) name = getattr(self, 'NAVIGATION_TAB_NAME', None)
if not name: if not name:
name = self.human_name name = self.human_name
@ -300,7 +325,9 @@ class NavigationMixin:
@property @property
def navigation_icon(self): def navigation_icon(self):
"""icon for navigation tab""" """
Icon-name for navigation tab
"""
return getattr(self, 'NAVIGATION_TAB_ICON', "fas fa-question") return getattr(self, 'NAVIGATION_TAB_ICON', "fas fa-question")
@ -310,7 +337,9 @@ class AppMixin:
""" """
class MixinMeta: class MixinMeta:
"""meta options for this mixin""" """m
Mta options for this mixin
"""
MIXIN_NAME = 'App registration' MIXIN_NAME = 'App registration'
def __init__(self): def __init__(self):
@ -320,7 +349,7 @@ class AppMixin:
@property @property
def has_app(self): def has_app(self):
""" """
this plugin is always an app with this plugin This plugin is always an app with this plugin
""" """
return True return True

View File

@ -17,7 +17,7 @@ from common.models import InvenTreeSetting
from InvenTree.ready import canAppAccessDatabase from InvenTree.ready import canAppAccessDatabase
from InvenTree.tasks import offload_task from InvenTree.tasks import offload_task
from plugin.registry import plugin_registry from plugin.registry import registry
logger = logging.getLogger('inventree') logger = logging.getLogger('inventree')
@ -64,7 +64,7 @@ def register_event(event, *args, **kwargs):
with transaction.atomic(): with transaction.atomic():
for slug, plugin in plugin_registry.plugins.items(): for slug, plugin in registry.plugins.items():
if plugin.mixin_enabled('events'): 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}'") 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) plugin.process_event(event, *args, **kwargs)

View File

@ -1,26 +1,23 @@
"""Helpers for plugin app""" """
Helpers for plugin app
"""
import os import os
import subprocess import subprocess
import pathlib import pathlib
import sysconfig import sysconfig
import traceback import traceback
import inspect
import pkgutil
from django.conf import settings from django.conf import settings
from django.core.exceptions import AppRegistryNotReady
# region logging / errors # 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): class IntegrationPluginError(Exception):
"""
Error that encapsulates another error and adds the path / reference of the raising plugin
"""
def __init__(self, path, message): def __init__(self, path, message):
self.path = path self.path = path
self.message = message self.message = message
@ -29,7 +26,39 @@ class IntegrationPluginError(Exception):
return self.message 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 package_path = traceback.extract_tb(error.__traceback__)[-1].filename
install_path = sysconfig.get_paths()["purelib"] install_path = sysconfig.get_paths()["purelib"]
try: try:
@ -53,18 +82,23 @@ def get_plugin_error(error, do_raise: bool = False, do_log: bool = False, log_na
log_kwargs = {} log_kwargs = {}
if log_name: if log_name:
log_kwargs['reference'] = 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: if do_raise:
raise IntegrationPluginError(package_name, str(error)) raise IntegrationPluginError(package_name, str(error))
return package_name, str(error) if do_return:
return new_error
# endregion # endregion
# region git-helpers # region git-helpers
def get_git_log(path): 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:] 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] command = ['git', 'log', '-n', '1', "--pretty=format:'%H%n%aN%n%aE%n%aI%n%f%n%G?%n%GK'", '--follow', '--', path]
try: try:
@ -79,9 +113,13 @@ def get_git_log(path):
class GitStatus: class GitStatus:
"""class for resolving git gpg singing state""" """
Class for resolving git gpg singing state
"""
class Definition: class Definition:
"""definition of a git gpg sing state""" """
Definition of a git gpg sing state
"""
key: str = 'N' key: str = 'N'
status: int = 2 status: int = 2
msg: str = '' msg: str = ''
@ -100,3 +138,56 @@ class GitStatus:
R = Definition(key='R', status=2, msg='good signature, revoked key',) R = Definition(key='R', status=2, msg='good signature, revoked key',)
E = Definition(key='E', status=1, msg='cannot be checked',) E = Definition(key='E', status=1, msg='cannot be checked',)
# endregion # 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 -*- # -*- coding: utf-8 -*-
"""class for IntegrationPluginBase and Mixins for it""" """
Class for IntegrationPluginBase and Mixin Base
"""
import logging import logging
import os import os
@ -11,7 +13,7 @@ from django.urls.base import reverse
from django.conf import settings from django.conf import settings
from django.utils.translation import ugettext_lazy as _ 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 from plugin.helpers import get_git_log, GitStatus
@ -20,7 +22,7 @@ logger = logging.getLogger("inventree")
class MixinBase: class MixinBase:
""" """
General base for mixins Base set of mixin functions and mechanisms
""" """
def __init__(self) -> None: def __init__(self) -> None:
@ -65,7 +67,7 @@ class MixinBase:
return mixins return mixins
class IntegrationPluginBase(MixinBase, plugin.InvenTreePlugin): class IntegrationPluginBase(MixinBase, plugin_base.InvenTreePluginBase):
""" """
The IntegrationPluginBase class is used to integrate with 3rd party software 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.def_path = inspect.getfile(self.__class__)
self.path = os.path.dirname(self.def_path) self.path = os.path.dirname(self.def_path)
self.set_package() self.define_package()
@property @property
def _is_package(self): def _is_package(self):
"""
Is the plugin delivered as a package
"""
return getattr(self, 'is_package', False) return getattr(self, 'is_package', False)
# region properties # region properties
@property @property
def slug(self): def slug(self):
"""
Slug of plugin
"""
return self.plugin_slug() return self.plugin_slug()
@property @property
def name(self): def name(self):
"""
Name of plugin
"""
return self.plugin_name() return self.plugin_name()
@property @property
def human_name(self): def human_name(self):
"""human readable name for labels etc.""" """
human_name = getattr(self, 'PLUGIN_TITLE', None) Human readable name of plugin
if not human_name: """
human_name = self.plugin_name() return self.plugin_title()
return human_name
@property @property
def description(self): def description(self):
"""description of plugin""" """
Description of plugin
"""
description = getattr(self, 'DESCRIPTION', None) description = getattr(self, 'DESCRIPTION', None)
if not description: if not description:
description = self.plugin_name() description = self.plugin_name()
@ -116,7 +128,9 @@ class IntegrationPluginBase(MixinBase, plugin.InvenTreePlugin):
@property @property
def author(self): 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) author = getattr(self, 'AUTHOR', None)
if not author: if not author:
author = self.package.get('author') author = self.package.get('author')
@ -126,7 +140,9 @@ class IntegrationPluginBase(MixinBase, plugin.InvenTreePlugin):
@property @property
def pub_date(self): 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) pub_date = getattr(self, 'PUBLISH_DATE', None)
if not pub_date: if not pub_date:
pub_date = self.package.get('date') pub_date = self.package.get('date')
@ -138,42 +154,56 @@ class IntegrationPluginBase(MixinBase, plugin.InvenTreePlugin):
@property @property
def version(self): def version(self):
"""returns version of plugin""" """
Version of plugin
"""
version = getattr(self, 'VERSION', None) version = getattr(self, 'VERSION', None)
return version return version
@property @property
def website(self): def website(self):
"""returns website of plugin""" """
Website of plugin - if set else None
"""
website = getattr(self, 'WEBSITE', None) website = getattr(self, 'WEBSITE', None)
return website return website
@property @property
def license(self): def license(self):
"""returns license of plugin""" """
License of plugin
"""
license = getattr(self, 'LICENSE', None) license = getattr(self, 'LICENSE', None)
return license return license
# endregion # endregion
@property @property
def package_path(self): def package_path(self):
"""returns the path to the plugin""" """
Path to the plugin
"""
if self._is_package: if self._is_package:
return self.__module__ return self.__module__
return pathlib.Path(self.def_path).relative_to(settings.BASE_DIR) return pathlib.Path(self.def_path).relative_to(settings.BASE_DIR)
@property @property
def settings_url(self): 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}' return f'{reverse("settings")}#select-plugin-{self.slug}'
# region mixins # region mixins
def mixin(self, key): def mixin(self, key):
"""check if mixin is registered""" """
Check if mixin is registered
"""
return key in self._mixins return key in self._mixins
def mixin_enabled(self, key): def mixin_enabled(self, key):
"""check if mixin is enabled and ready""" """
Check if mixin is registered, enabled and ready
"""
if self.mixin(key): if self.mixin(key):
fnc_name = self._mixins.get(key) fnc_name = self._mixins.get(key)
@ -186,17 +216,23 @@ class IntegrationPluginBase(MixinBase, plugin.InvenTreePlugin):
# endregion # endregion
# region package info # region package info
def get_package_commit(self): def _get_package_commit(self):
"""get last git commit for plugin""" """
Get last git commit for the plugin
"""
return get_git_log(self.def_path) return get_git_log(self.def_path)
def get_package_metadata(self): def _get_package_metadata(self):
"""get package metadata for plugin""" """
Get package metadata for plugin
"""
return {} return {}
def set_package(self): def define_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() 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 # process date
if package.get('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 django.template.loaders.filesystem import Loader as FilesystemLoader
from pathlib import Path from pathlib import Path
from plugin import plugin_registry from plugin import registry
class PluginTemplateLoader(FilesystemLoader): class PluginTemplateLoader(FilesystemLoader):
@ -12,7 +12,7 @@ class PluginTemplateLoader(FilesystemLoader):
def get_dirs(self): def get_dirs(self):
dirname = 'templates' dirname = 'templates'
template_dirs = [] template_dirs = []
for plugin in plugin_registry.plugins.values(): for plugin in registry.plugins.values():
new_path = Path(plugin.path) / dirname new_path = Path(plugin.path) / dirname
if Path(new_path).is_dir(): if Path(new_path).is_dir():
template_dirs.append(new_path) 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.integration.mixins import APICallMixin, AppMixin, SettingsMixin, EventMixin, ScheduleMixin, UrlsMixin, NavigationMixin
from ..builtin.action.mixins import ActionMixin
from ..builtin.barcode.mixins import BarcodeMixin
__all__ = [ __all__ = [
'APICallMixin', 'APICallMixin',
@ -12,4 +14,6 @@ __all__ = [
'ScheduleMixin', 'ScheduleMixin',
'SettingsMixin', 'SettingsMixin',
'UrlsMixin', 'UrlsMixin',
'ActionMixin',
'BarcodeMixin',
] ]

View File

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

View File

@ -2,14 +2,16 @@
""" """
Base Class for InvenTree plugins Base Class for InvenTree plugins
""" """
import warnings
from django.db.utils import OperationalError, ProgrammingError from django.db.utils import OperationalError, ProgrammingError
from django.utils.text import slugify from django.utils.text import slugify
class InvenTreePlugin(): class InvenTreePluginBase():
""" """
Base class for a plugin Base class for a plugin
DO NOT USE THIS DIRECTLY, USE plugin.IntegrationPluginBase
""" """
def __init__(self): def __init__(self):
@ -24,11 +26,15 @@ class InvenTreePlugin():
def plugin_name(self): def plugin_name(self):
""" """
Return the name of this plugin plugin Name of plugin
""" """
return self.PLUGIN_NAME return self.PLUGIN_NAME
def plugin_slug(self): def plugin_slug(self):
"""
Slug of plugin
If not set plugin name slugified
"""
slug = getattr(self, 'PLUGIN_SLUG', None) slug = getattr(self, 'PLUGIN_SLUG', None)
@ -38,6 +44,9 @@ class InvenTreePlugin():
return slugify(slug.lower()) return slugify(slug.lower())
def plugin_title(self): def plugin_title(self):
"""
Title of plugin
"""
if self.PLUGIN_TITLE: if self.PLUGIN_TITLE:
return self.PLUGIN_TITLE return self.PLUGIN_TITLE
@ -75,3 +84,13 @@ class InvenTreePlugin():
return cfg.active return cfg.active
else: else:
return False 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 maintenance_mode_on
from maintenance_mode.core import get_maintenance_mode, set_maintenance_mode from maintenance_mode.core import get_maintenance_mode, set_maintenance_mode
from plugin import plugins as inventree_plugins
from .integration import IntegrationPluginBase 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') logger = logging.getLogger('inventree')
@ -60,18 +59,16 @@ class PluginsRegistry:
# mixins # mixins
self.mixins_settings = {} self.mixins_settings = {}
# region public plugin functions # region public functions
# region loading / unloading
def load_plugins(self): def load_plugins(self):
""" """
Load and activate all IntegrationPlugins Load and activate all IntegrationPlugins
""" """
if not settings.PLUGINS_ENABLED: if not settings.PLUGINS_ENABLED:
# Plugins not enabled, do nothing # Plugins not enabled, do nothing
return return
from plugin.helpers import log_plugin_error
logger.info('Start loading plugins') logger.info('Start loading plugins')
# Set maintanace mode # Set maintanace mode
@ -95,7 +92,7 @@ class PluginsRegistry:
break break
except IntegrationPluginError as error: except IntegrationPluginError as error:
logger.error(f'[PLUGIN] Encountered an error with {error.path}:\n{error.message}') 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 blocked_plugin = error.path # we will not try to load this app again
# Initialize apps without any integration plugins # Initialize apps without any integration plugins
@ -179,7 +176,7 @@ class PluginsRegistry:
# Collect plugins from paths # Collect plugins from paths
for plugin in settings.PLUGIN_DIRS: 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: if modules:
[self.plugin_modules.append(item) for item in modules] [self.plugin_modules.append(item) for item in modules]
@ -192,12 +189,29 @@ class PluginsRegistry:
plugin.is_package = True plugin.is_package = True
self.plugin_modules.append(plugin) self.plugin_modules.append(plugin)
except Exception as error: 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 # Log collected plugins
logger.info(f'Collected {len(self.plugin_modules)} plugins!') logger.info(f'Collected {len(self.plugin_modules)} plugins!')
logger.info(", ".join([a.__module__ for a in self.plugin_modules])) 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): def _init_plugins(self, disabled=None):
""" """
Initialise all found plugins Initialise all found plugins
@ -254,7 +268,7 @@ class PluginsRegistry:
plugin = plugin() plugin = plugin()
except Exception as error: except Exception as error:
# log error and raise it -> disable plugin # 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}') logger.info(f'Loaded integration plugin {plugin.slug}')
plugin.is_package = was_packaged plugin.is_package = was_packaged
@ -290,7 +304,9 @@ class PluginsRegistry:
self.deactivate_integration_app() self.deactivate_integration_app()
self.deactivate_integration_schedule() self.deactivate_integration_schedule()
self.deactivate_integration_settings() self.deactivate_integration_settings()
# endregion
# region mixin specific loading ...
def activate_integration_settings(self, plugins): def activate_integration_settings(self, plugins):
logger.info('Activating plugin settings') logger.info('Activating plugin settings')
@ -536,7 +552,8 @@ class PluginsRegistry:
cmd(*args, **kwargs) cmd(*args, **kwargs)
return True, [] return True, []
except Exception as error: 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 django.test import TestCase
from plugin import plugin_registry from plugin import registry
class SampleApiCallerPluginTests(TestCase): class SampleApiCallerPluginTests(TestCase):
@ -11,8 +11,8 @@ class SampleApiCallerPluginTests(TestCase):
def test_return(self): def test_return(self):
"""check if the external api call works""" """check if the external api call works"""
# The plugin should be defined # The plugin should be defined
self.assertIn('sample-api-caller', plugin_registry.plugins) self.assertIn('sample-api-caller', registry.plugins)
plg = plugin_registry.plugins['sample-api-caller'] plg = registry.plugins['sample-api-caller']
self.assertTrue(plg) self.assertTrue(plg)
# do an api call # do an api call

View File

@ -7,7 +7,7 @@ from django import template
from django.urls import reverse from django.urls import reverse
from common.models import InvenTreeSetting from common.models import InvenTreeSetting
from plugin import plugin_registry from plugin import registry
register = template.Library() register = template.Library()
@ -15,31 +15,41 @@ register = template.Library()
@register.simple_tag() @register.simple_tag()
def plugin_list(*args, **kwargs): 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() @register.simple_tag()
def inactive_plugin_list(*args, **kwargs): 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() @register.simple_tag()
def plugin_settings(plugin, *args, **kwargs): 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() @register.simple_tag()
def mixin_enabled(plugin, key, *args, **kwargs): 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) return plugin.mixin_enabled(key)
@register.simple_tag() @register.simple_tag()
def navigation_enabled(*args, **kwargs): def navigation_enabled(*args, **kwargs):
"""Return if plugin navigation is enabled""" """
Is plugin navigation enabled?
"""
if djangosettings.PLUGIN_TESTING: if djangosettings.PLUGIN_TESTING:
return True return True
return InvenTreeSetting.get_setting('ENABLE_PLUGINS_NAVIGATION') return InvenTreeSetting.get_setting('ENABLE_PLUGINS_NAVIGATION')
@ -47,7 +57,10 @@ def navigation_enabled(*args, **kwargs):
@register.simple_tag() @register.simple_tag()
def safe_url(view_name, *args, **kwargs): def safe_url(view_name, *args, **kwargs):
""" safe lookup for urls """ """
Safe lookup fnc for URLs
Returns None if not found
"""
try: try:
return reverse(view_name, args=args, kwargs=kwargs) return reverse(view_name, args=args, kwargs=kwargs)
except: except:
@ -56,5 +69,7 @@ def safe_url(view_name, *args, **kwargs):
@register.simple_tag() @register.simple_tag()
def plugin_errors(*args, **kwargs): 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 Test the PluginConfig action commands
""" """
from plugin.models import PluginConfig from plugin.models import PluginConfig
from plugin import plugin_registry from plugin import registry
url = reverse('admin:plugin_pluginconfig_changelist') url = reverse('admin:plugin_pluginconfig_changelist')
fixtures = PluginConfig.objects.all() fixtures = PluginConfig.objects.all()
# check if plugins were registered -> in some test setups the startup has no db access # check if plugins were registered -> in some test setups the startup has no db access
if not fixtures: if not fixtures:
plugin_registry.reload_plugins() registry.reload_plugins()
fixtures = PluginConfig.objects.all() fixtures = PluginConfig.objects.all()
print([str(a) for a in fixtures]) print([str(a) for a in fixtures])

View File

@ -4,20 +4,18 @@ Unit tests for plugins
from django.test import TestCase from django.test import TestCase
import plugin.plugin
import plugin.integration
from plugin.samples.integration.sample import SampleIntegrationPlugin from plugin.samples.integration.sample import SampleIntegrationPlugin
from plugin.samples.integration.another_sample import WrongIntegrationPlugin, NoIntegrationPlugin from plugin.samples.integration.another_sample import WrongIntegrationPlugin, NoIntegrationPlugin
import plugin.templatetags.plugin_extras as plugin_tags import plugin.templatetags.plugin_extras as plugin_tags
from plugin import plugin_registry from plugin import registry, InvenTreePluginBase
class InvenTreePluginTests(TestCase): class InvenTreePluginTests(TestCase):
""" Tests for InvenTreePlugin """ """ Tests for InvenTreePlugin """
def setUp(self): def setUp(self):
self.plugin = plugin.plugin.InvenTreePlugin() self.plugin = InvenTreePluginBase()
class NamedPlugin(plugin.plugin.InvenTreePlugin): class NamedPlugin(InvenTreePluginBase):
"""a named plugin""" """a named plugin"""
PLUGIN_NAME = 'abc123' PLUGIN_NAME = 'abc123'
@ -34,20 +32,6 @@ class InvenTreePluginTests(TestCase):
self.assertEqual(self.named_plugin.plugin_name(), 'abc123') 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): class PluginTagTests(TestCase):
""" Tests for the plugin extras """ """ Tests for the plugin extras """
@ -58,17 +42,17 @@ class PluginTagTests(TestCase):
def test_tag_plugin_list(self): def test_tag_plugin_list(self):
"""test that all plugins are listed""" """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): def test_tag_incative_plugin_list(self):
"""test that all inactive plugins are listed""" """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): def test_tag_plugin_settings(self):
"""check all plugins are listed""" """check all plugins are listed"""
self.assertEqual( self.assertEqual(
plugin_tags.plugin_settings(self.sample), 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): def test_tag_mixin_enabled(self):
@ -90,4 +74,4 @@ class PluginTagTests(TestCase):
def test_tag_plugin_errors(self): def test_tag_plugin_errors(self):
"""test that all errors are listed""" """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 django.conf.urls import url, include
from plugin import plugin_registry from plugin import registry
PLUGIN_BASE = 'plugin' # Constant for links PLUGIN_BASE = 'plugin' # Constant for links
@ -17,7 +17,7 @@ def get_plugin_urls():
urls = [] urls = []
for plugin in plugin_registry.plugins.values(): for plugin in registry.plugins.values():
if plugin.mixin_enabled('urls'): if plugin.mixin_enabled('urls'):
urls.append(plugin.urlpatterns) 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', field: 'actions',
title: '', title: '',
switchable: false,
formatter: function(value, row) { formatter: function(value, row) {
if (row.received >= row.quantity) { if (row.received >= row.quantity) {