mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-10-31 13:15:43 +00:00 
			
		
		
		
	Merge pull request #2525 from matmair/matmair/issue2519
Refactor action and barcode plugins and cleanup
This commit is contained in:
		| @@ -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) | ||||
|   | ||||
| @@ -258,9 +258,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 | ||||
|   | ||||
| @@ -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) | ||||
|  | ||||
|   | ||||
		Reference in New Issue
	
	Block a user