mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge branch 'inventree:master' into matmair/issue2279
This commit is contained in:
commit
c490574082
@ -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()
|
||||
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
|
@ -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',
|
||||
]
|
||||
|
@ -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)
|
||||
|
@ -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)')
|
||||
|
@ -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
|
||||
|
68
InvenTree/plugin/builtin/action/mixins.py
Normal file
68
InvenTree/plugin/builtin/action/mixins.py
Normal 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(),
|
||||
}
|
0
InvenTree/plugin/builtin/barcode/__init__.py
Normal file
0
InvenTree/plugin/builtin/barcode/__init__.py
Normal file
146
InvenTree/plugin/builtin/barcode/mixins.py
Normal file
146
InvenTree/plugin/builtin/barcode/mixins.py
Normal 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
|
@ -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
|
||||
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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
|
||||
|
@ -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'):
|
||||
|
@ -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)
|
||||
|
@ -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',
|
||||
]
|
||||
|
@ -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
|
||||
|
||||
|
@ -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__()
|
||||
|
@ -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)
|
@ -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()
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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])
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
||||
|
19
InvenTree/templates/500.html
Normal file
19
InvenTree/templates/500.html
Normal 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 %}
|
@ -929,6 +929,7 @@ function loadPartPurchaseOrderTable(table, part_id, options={}) {
|
||||
{
|
||||
field: 'actions',
|
||||
title: '',
|
||||
switchable: false,
|
||||
formatter: function(value, row) {
|
||||
|
||||
if (row.received >= row.quantity) {
|
||||
|
Loading…
Reference in New Issue
Block a user