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 -*-
|
# -*- 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()
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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
|
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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,
|
||||||
|
@ -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',
|
||||||
]
|
]
|
||||||
|
@ -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(),
|
|
||||||
}
|
|
||||||
|
@ -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)')
|
||||||
|
@ -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
|
||||||
|
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.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
|
||||||
|
|
||||||
|
@ -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)
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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'):
|
||||||
|
@ -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)
|
||||||
|
@ -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',
|
||||||
]
|
]
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
@ -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__()
|
||||||
|
@ -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 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()
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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])
|
||||||
|
@ -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)
|
||||||
|
@ -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)
|
||||||
|
|
||||||
|
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',
|
field: 'actions',
|
||||||
title: '',
|
title: '',
|
||||||
|
switchable: false,
|
||||||
formatter: function(value, row) {
|
formatter: function(value, row) {
|
||||||
|
|
||||||
if (row.received >= row.quantity) {
|
if (row.received >= row.quantity) {
|
||||||
|
Loading…
Reference in New Issue
Block a user