diff --git a/InvenTree/InvenTree/api_tester.py b/InvenTree/InvenTree/api_tester.py index 4e55094e4b..1669b90ea5 100644 --- a/InvenTree/InvenTree/api_tester.py +++ b/InvenTree/InvenTree/api_tester.py @@ -134,7 +134,7 @@ class InvenTreeAPITestCase(UserMixin, APITestCase): if expected_code is not None: if response.status_code != expected_code: - print(f"Unexpected response at '{url}':") + print(f"Unexpected response at '{url}': status_code = {response.status_code}") print(response.data) self.assertEqual(response.status_code, expected_code) @@ -143,11 +143,13 @@ class InvenTreeAPITestCase(UserMixin, APITestCase): def post(self, url, data=None, expected_code=None, format='json'): """Issue a POST request.""" - response = self.client.post(url, data=data, format=format) + # Set default value - see B006 if data is None: data = {} + response = self.client.post(url, data=data, format=format) + if expected_code is not None: if response.status_code != expected_code: diff --git a/InvenTree/InvenTree/static/script/inventree/inventree.js b/InvenTree/InvenTree/static/script/inventree/inventree.js index f0d65b9131..0f06ea98ac 100644 --- a/InvenTree/InvenTree/static/script/inventree/inventree.js +++ b/InvenTree/InvenTree/static/script/inventree/inventree.js @@ -127,13 +127,6 @@ function inventreeDocReady() { loadBrandIcon($(this), $(this).attr('brand_name')); }); - // Callback for "admin view" button - $('#admin-button, .admin-button').click(function() { - var url = $(this).attr('url'); - - location.href = url; - }); - // Display any cached alert messages showCachedAlerts(); diff --git a/InvenTree/InvenTree/test_api.py b/InvenTree/InvenTree/test_api.py index 49e98815a5..0b43f44cd0 100644 --- a/InvenTree/InvenTree/test_api.py +++ b/InvenTree/InvenTree/test_api.py @@ -178,11 +178,15 @@ class APITests(InvenTreeAPITestCase): def test_with_roles(self): """Assign some roles to the user.""" self.basicAuth() - response = self.get(reverse('api-user-roles')) + + url = reverse('api-user-roles') + + response = self.get(url) self.assignRole('part.delete') self.assignRole('build.change') - response = self.get(reverse('api-user-roles')) + + response = self.get(url) roles = response.data['roles'] diff --git a/InvenTree/build/test_api.py b/InvenTree/build/test_api.py index 33c4d541f5..66185b3e54 100644 --- a/InvenTree/build/test_api.py +++ b/InvenTree/build/test_api.py @@ -213,7 +213,7 @@ class BuildTest(BuildAPITest): "location": 1, "status": 50, # Item requires attention }, - expected_code=201 + expected_code=201, ) self.assertEqual(self.build.incomplete_outputs.count(), 0) diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py index 51b786558e..f656dbf82d 100644 --- a/InvenTree/common/models.py +++ b/InvenTree/common/models.py @@ -1332,7 +1332,7 @@ class InvenTreeSetting(BaseInvenTreeSetting): 'PLUGIN_ON_STARTUP': { 'name': _('Check plugins on startup'), - 'description': _('Check that all plugins are installed on startup - enable in container enviroments'), + 'description': _('Check that all plugins are installed on startup - enable in container environments'), 'default': False, 'validator': bool, 'requires_restart': True, diff --git a/InvenTree/label/api.py b/InvenTree/label/api.py index 64bea60db3..9a34a1a2a2 100644 --- a/InvenTree/label/api.py +++ b/InvenTree/label/api.py @@ -67,9 +67,7 @@ class LabelPrintMixin: plugin = registry.get_plugin(plugin_key) if plugin: - config = plugin.plugin_config() - - if config and config.active: + if plugin.is_active(): # Only return the plugin if it is enabled! return plugin else: diff --git a/InvenTree/part/test_api.py b/InvenTree/part/test_api.py index 635e4305ae..8b9b941765 100644 --- a/InvenTree/part/test_api.py +++ b/InvenTree/part/test_api.py @@ -593,7 +593,7 @@ class PartAPITest(InvenTreeAPITestCase): { 'convert_from': variant.pk, }, - expected_code=200 + expected_code=200, ) # There should be the same number of results for each request @@ -1854,7 +1854,7 @@ class BomItemTest(InvenTreeAPITestCase): data={ 'validated': True, }, - expected_code=200 + expected_code=200, ) # Check that the expected response is returned diff --git a/InvenTree/plugin/admin.py b/InvenTree/plugin/admin.py index 7bb0558600..253c347e9c 100644 --- a/InvenTree/plugin/admin.py +++ b/InvenTree/plugin/admin.py @@ -52,7 +52,7 @@ class PluginConfigAdmin(admin.ModelAdmin): """Custom admin with restricted id fields.""" readonly_fields = ["key", "name", ] - list_display = ['name', 'key', '__str__', 'active', 'is_sample'] + list_display = ['name', 'key', '__str__', 'active', 'is_builtin', 'is_sample'] list_filter = ['active'] actions = [plugin_activate, plugin_deactivate, ] inlines = [PluginSettingInline, ] diff --git a/InvenTree/plugin/apps.py b/InvenTree/plugin/apps.py index bd56e05709..28f1acac8e 100644 --- a/InvenTree/plugin/apps.py +++ b/InvenTree/plugin/apps.py @@ -7,7 +7,6 @@ The main code for plugin special sauce is in the plugin registry in `InvenTree/p import logging from django.apps import AppConfig -from django.conf import settings from django.utils.translation import gettext_lazy as _ from maintenance_mode.core import set_maintenance_mode @@ -26,34 +25,34 @@ class PluginAppConfig(AppConfig): def ready(self): """The ready method is extended to initialize plugins.""" - if settings.PLUGINS_ENABLED: - if not canAppAccessDatabase(allow_test=True, allow_plugins=True): - logger.info("Skipping plugin loading sequence") # pragma: no cover - else: - logger.info('Loading InvenTree plugins') + if not canAppAccessDatabase(allow_test=True, allow_plugins=True): + logger.info("Skipping plugin loading sequence") # pragma: no cover + else: + logger.info('Loading InvenTree plugins') - if not registry.is_loading: - # this is the first startup - try: - from common.models import InvenTreeSetting - if InvenTreeSetting.get_setting('PLUGIN_ON_STARTUP', create=False, cache=False): - # make sure all plugins are installed - registry.install_plugin_file() - except Exception: # pragma: no cover - pass + if not registry.is_loading: + # this is the first startup + try: + from common.models import InvenTreeSetting + if InvenTreeSetting.get_setting('PLUGIN_ON_STARTUP', create=False, cache=False): + # make sure all plugins are installed + registry.install_plugin_file() + except Exception: # pragma: no cover + pass - # get plugins and init them - registry.plugin_modules = registry.collect_plugins() - registry.load_plugins() + # get plugins and init them + registry.plugin_modules = 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 - set_maintenance_mode(False) + # drop out of maintenance + # makes sure we did not have an error in reloading and maintenance is still active + set_maintenance_mode(False) - # check git version - registry.git_is_modern = check_git_version() - if not registry.git_is_modern: # pragma: no cover # simulating old git seems not worth it for coverage - log_error(_('Your enviroment has an outdated git version. This prevents InvenTree from loading plugin details.'), 'load') + # check git version + registry.git_is_modern = check_git_version() + + if not registry.git_is_modern: # pragma: no cover # simulating old git seems not worth it for coverage + log_error(_('Your environment has an outdated git version. This prevents InvenTree from loading plugin details.'), 'load') else: logger.info("Plugins not enabled - skipping loading sequence") # pragma: no cover diff --git a/InvenTree/plugin/base/barcodes/api.py b/InvenTree/plugin/base/barcodes/api.py index 8f7fdafdaa..bc6937a154 100644 --- a/InvenTree/plugin/base/barcodes/api.py +++ b/InvenTree/plugin/base/barcodes/api.py @@ -11,8 +11,8 @@ from rest_framework.views import APIView from InvenTree.helpers import hash_barcode from plugin import registry -from plugin.builtin.barcodes.inventree_barcode import ( - InvenTreeExternalBarcodePlugin, InvenTreeInternalBarcodePlugin) +from plugin.builtin.barcodes.inventree_barcode import \ + InvenTreeInternalBarcodePlugin from users.models import RuleSet @@ -53,11 +53,8 @@ class BarcodeScan(APIView): if not barcode_data: raise ValidationError({'barcode': _('Missing barcode data')}) - # Ensure that the default barcode handlers are run first - plugins = [ - InvenTreeInternalBarcodePlugin(), - InvenTreeExternalBarcodePlugin(), - ] + registry.with_mixin('barcode') + # Note: the default barcode handlers are loaded (and thus run) first + plugins = registry.with_mixin('barcode') barcode_hash = hash_barcode(barcode_data) @@ -113,10 +110,7 @@ class BarcodeAssign(APIView): raise ValidationError({'barcode': _('Missing barcode data')}) # Here we only check against 'InvenTree' plugins - plugins = [ - InvenTreeInternalBarcodePlugin(), - InvenTreeExternalBarcodePlugin(), - ] + plugins = registry.with_mixin('barcode', builtin=True) # First check if the provided barcode matches an existing database entry for plugin in plugins: @@ -133,7 +127,7 @@ class BarcodeAssign(APIView): valid_labels = [] - for model in InvenTreeExternalBarcodePlugin.get_supported_barcode_models(): + for model in InvenTreeInternalBarcodePlugin.get_supported_barcode_models(): label = model.barcode_model_type() valid_labels.append(label) @@ -188,7 +182,7 @@ class BarcodeUnassign(APIView): """Respond to a barcode unassign POST request""" # The following database models support assignment of third-party barcodes - supported_models = InvenTreeExternalBarcodePlugin.get_supported_barcode_models() + supported_models = InvenTreeInternalBarcodePlugin.get_supported_barcode_models() supported_labels = [model.barcode_model_type() for model in supported_models] model_names = ', '.join(supported_labels) diff --git a/InvenTree/plugin/base/event/events.py b/InvenTree/plugin/base/event/events.py index 380fc5587a..4b826d8aaa 100644 --- a/InvenTree/plugin/base/event/events.py +++ b/InvenTree/plugin/base/event/events.py @@ -58,9 +58,8 @@ def register_event(event, *args, **kwargs): if plugin.mixin_enabled('events'): - config = plugin.plugin_config() - - if config and config.active: + if plugin.is_active(): + # Only allow event registering for 'active' plugins logger.debug(f"Registering callback for plugin '{slug}'") diff --git a/InvenTree/plugin/builtin/action/__init__.py b/InvenTree/plugin/builtin/action/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/InvenTree/plugin/builtin/barcodes/inventree_barcode.py b/InvenTree/plugin/builtin/barcodes/inventree_barcode.py index 4fbe20ca27..ab0b302afc 100644 --- a/InvenTree/plugin/builtin/barcodes/inventree_barcode.py +++ b/InvenTree/plugin/builtin/barcodes/inventree_barcode.py @@ -9,6 +9,8 @@ references model objects actually exist in the database. import json +from django.utils.translation import gettext_lazy as _ + from company.models import SupplierPart from InvenTree.helpers import hash_barcode from part.models import Part @@ -17,8 +19,14 @@ from plugin.mixins import BarcodeMixin from stock.models import StockItem, StockLocation -class InvenTreeBarcodePlugin(BarcodeMixin, InvenTreePlugin): - """Generic base class for handling InvenTree barcodes""" +class InvenTreeInternalBarcodePlugin(BarcodeMixin, InvenTreePlugin): + """Builtin BarcodePlugin for matching and generating internal barcodes.""" + + NAME = "InvenTreeBarcode" + TITLE = _("Inventree Barcodes") + DESCRIPTION = _("Provides native support for barcodes") + VERSION = "2.0.0" + AUTHOR = _("InvenTree contributors") @staticmethod def get_supported_barcode_models(): @@ -58,57 +66,42 @@ class InvenTreeBarcodePlugin(BarcodeMixin, InvenTreePlugin): return response - -class InvenTreeInternalBarcodePlugin(InvenTreeBarcodePlugin): - """Builtin BarcodePlugin for matching and generating internal barcodes.""" - - NAME = "InvenTreeInternalBarcode" - def scan(self, barcode_data): """Scan a barcode against this plugin. Here we are looking for a dict object which contains a reference to a particular InvenTree database object """ + # Create hash from raw barcode data + barcode_hash = hash_barcode(barcode_data) + + # Attempt to coerce the barcode data into a dict object + # This is the internal barcode representation that InvenTree uses + barcode_dict = None + if type(barcode_data) is dict: - pass + barcode_dict = barcode_data elif type(barcode_data) is str: try: - barcode_data = json.loads(barcode_data) + barcode_dict = json.loads(barcode_data) except json.JSONDecodeError: - return None - else: - return None + pass - if type(barcode_data) is not dict: - return None + if barcode_dict is not None and type(barcode_dict) is dict: + # Look for various matches. First good match will be returned + for model in self.get_supported_barcode_models(): + label = model.barcode_model_type() - # Look for various matches. First good match will be returned + if label in barcode_dict: + try: + instance = model.objects.get(pk=barcode_dict[label]) + return self.format_matched_response(label, model, instance) + except (ValueError, model.DoesNotExist): + pass + + # If no "direct" hits are found, look for assigned third-party barcodes for model in self.get_supported_barcode_models(): label = model.barcode_model_type() - if label in barcode_data: - try: - instance = model.objects.get(pk=barcode_data[label]) - return self.format_matched_response(label, model, instance) - except (ValueError, model.DoesNotExist): - pass - - -class InvenTreeExternalBarcodePlugin(InvenTreeBarcodePlugin): - """Builtin BarcodePlugin for matching arbitrary external barcodes.""" - - NAME = "InvenTreeExternalBarcode" - - def scan(self, barcode_data): - """Scan a barcode against this plugin. - - Here we are looking for a dict object which contains a reference to a particular InvenTree databse object - """ - - for model in self.get_supported_barcode_models(): - label = model.barcode_model_type() - - barcode_hash = hash_barcode(barcode_data) instance = model.lookup_barcode(barcode_hash) diff --git a/InvenTree/plugin/builtin/barcodes/test_inventree_barcode.py b/InvenTree/plugin/builtin/barcodes/test_inventree_barcode.py index 6f2439de23..d998f0172f 100644 --- a/InvenTree/plugin/builtin/barcodes/test_inventree_barcode.py +++ b/InvenTree/plugin/builtin/barcodes/test_inventree_barcode.py @@ -29,7 +29,7 @@ class TestInvenTreeBarcode(InvenTreeAPITestCase): 'barcode': barcode_data, 'stockitem': 521 }, - expected_code=400 + expected_code=400, ) self.assertIn('error', response.data) @@ -250,7 +250,7 @@ class TestInvenTreeBarcode(InvenTreeAPITestCase): ) self.assertIn('success', response.data) - self.assertEqual(response.data['plugin'], 'InvenTreeExternalBarcode') + self.assertEqual(response.data['plugin'], 'InvenTreeBarcode') self.assertEqual(response.data['part']['pk'], 1) # Attempting to assign the same barcode to a different part should result in an error @@ -347,7 +347,7 @@ class TestInvenTreeBarcode(InvenTreeAPITestCase): response = self.scan({'barcode': 'blbla=10004'}, expected_code=200) self.assertEqual(response.data['barcode_data'], 'blbla=10004') - self.assertEqual(response.data['plugin'], 'InvenTreeExternalBarcode') + self.assertEqual(response.data['plugin'], 'InvenTreeBarcode') # Scan for a StockItem instance si = stock.models.StockItem.objects.get(pk=1) @@ -402,7 +402,7 @@ class TestInvenTreeBarcode(InvenTreeAPITestCase): self.assertEqual(response.data['stocklocation']['pk'], 5) self.assertEqual(response.data['stocklocation']['api_url'], '/api/stock/location/5/') self.assertEqual(response.data['stocklocation']['web_url'], '/stock/location/5/') - self.assertEqual(response.data['plugin'], 'InvenTreeInternalBarcode') + self.assertEqual(response.data['plugin'], 'InvenTreeBarcode') # Scan a Part object response = self.scan( @@ -423,7 +423,7 @@ class TestInvenTreeBarcode(InvenTreeAPITestCase): ) self.assertEqual(response.data['supplierpart']['pk'], 1) - self.assertEqual(response.data['plugin'], 'InvenTreeInternalBarcode') + self.assertEqual(response.data['plugin'], 'InvenTreeBarcode') self.assertIn('success', response.data) self.assertIn('barcode_data', response.data) diff --git a/InvenTree/plugin/builtin/integration/core_notifications.py b/InvenTree/plugin/builtin/integration/core_notifications.py index 2176ab9feb..67ca3e498e 100644 --- a/InvenTree/plugin/builtin/integration/core_notifications.py +++ b/InvenTree/plugin/builtin/integration/core_notifications.py @@ -27,8 +27,10 @@ class CoreNotificationsPlugin(SettingsMixin, InvenTreePlugin): """Core notification methods for InvenTree.""" NAME = "CoreNotificationsPlugin" + TITLE = _("InvenTree Notifications") AUTHOR = _('InvenTree contributors') DESCRIPTION = _('Integrated outgoing notificaton methods') + VERSION = "1.0.0" SETTINGS = { 'ENABLE_NOTIFICATION_EMAILS': { diff --git a/InvenTree/plugin/models.py b/InvenTree/plugin/models.py index 7eca4e1354..5af7478a39 100644 --- a/InvenTree/plugin/models.py +++ b/InvenTree/plugin/models.py @@ -158,16 +158,20 @@ class PluginConfig(models.Model): @admin.display(boolean=True, description=_('Sample plugin')) def is_sample(self) -> bool: """Is this plugin a sample app?""" - # Loaded and active plugin - if isinstance(self.plugin, InvenTreePlugin): - return self.plugin.check_is_sample() - # If no plugin_class is available it can not be a sample if not self.plugin: return False - # Not loaded plugin - return self.plugin.check_is_sample() # pragma: no cover + return self.plugin.check_is_sample() + + @admin.display(boolean=True, description=_('Builtin Plugin')) + def is_builtin(self) -> bool: + """Return True if this is a 'builtin' plugin""" + + if not self.plugin: + return False + + return self.plugin.check_is_builtin() class PluginSetting(common.models.BaseInvenTreeSetting): diff --git a/InvenTree/plugin/plugin.py b/InvenTree/plugin/plugin.py index ece0395092..806ee182a2 100644 --- a/InvenTree/plugin/plugin.py +++ b/InvenTree/plugin/plugin.py @@ -106,10 +106,15 @@ class MetaBase: def is_active(self): """Return True if this plugin is currently active.""" - cfg = self.plugin_config() - if cfg: - return cfg.active + # Builtin plugins are always considered "active" + if self.is_builtin: + return True + + config = self.plugin_config() + + if config: + return config.active else: return False # pragma: no cover @@ -300,6 +305,16 @@ class InvenTreePlugin(VersionMixin, MixinBase, MetaBase): """Is this plugin part of the samples?""" return self.check_is_sample() + @classmethod + def check_is_builtin(cls) -> bool: + """Determine if a particular plugin class is a 'builtin' plugin""" + return str(cls.check_package_path()).startswith('plugin/builtin') + + @property + def is_builtin(self) -> bool: + """Is this plugin is builtin""" + return self.check_is_builtin() + @classmethod def check_package_path(cls): """Path to the plugin.""" diff --git a/InvenTree/plugin/registry.py b/InvenTree/plugin/registry.py index bc832fa128..48357a2fe5 100644 --- a/InvenTree/plugin/registry.py +++ b/InvenTree/plugin/registry.py @@ -108,9 +108,6 @@ class PluginsRegistry: Args: full_reload (bool, optional): Reload everything - including plugin mechanism. Defaults to False. """ - if not settings.PLUGINS_ENABLED: - # Plugins not enabled, do nothing - return # pragma: no cover logger.info('Start loading plugins') @@ -167,9 +164,6 @@ class PluginsRegistry: def unload_plugins(self): """Unload and deactivate all IntegrationPlugins.""" - if not settings.PLUGINS_ENABLED: - # Plugins not enabled, do nothing - return # pragma: no cover logger.info('Start unloading plugins') @@ -187,6 +181,7 @@ class PluginsRegistry: # remove maintenance if not _maintenance: set_maintenance_mode(False) # pragma: no cover + logger.info('Finished unloading plugins') def reload_plugins(self, full_reload: bool = False): @@ -210,62 +205,63 @@ class PluginsRegistry: def plugin_dirs(self): """Construct a list of directories from where plugins can be loaded""" + # Builtin plugins are *always* loaded dirs = ['plugin.builtin', ] - if settings.TESTING or settings.DEBUG: - # If in TEST or DEBUG mode, load plugins from the 'samples' directory - dirs.append('plugin.samples') + if settings.PLUGINS_ENABLED: + # Any 'external' plugins are only loaded if PLUGINS_ENABLED is set to True - if settings.TESTING: - custom_dirs = os.getenv('INVENTREE_PLUGIN_TEST_DIR', None) - else: # pragma: no cover - custom_dirs = get_setting('INVENTREE_PLUGIN_DIR', 'plugin_dir') + if settings.TESTING or settings.DEBUG: + # If in TEST or DEBUG mode, load plugins from the 'samples' directory + dirs.append('plugin.samples') - # Load from user specified directories (unless in testing mode) - dirs.append('plugins') + if settings.TESTING: + custom_dirs = os.getenv('INVENTREE_PLUGIN_TEST_DIR', None) + else: # pragma: no cover + custom_dirs = get_setting('INVENTREE_PLUGIN_DIR', 'plugin_dir') - if custom_dirs is not None: - # Allow multiple plugin directories to be specified - for pd_text in custom_dirs.split(','): - pd = Path(pd_text.strip()).absolute() + # Load from user specified directories (unless in testing mode) + dirs.append('plugins') - # Attempt to create the directory if it does not already exist - if not pd.exists(): - try: - pd.mkdir(exist_ok=True) - except Exception: # pragma: no cover - logger.error(f"Could not create plugin directory '{pd}'") - continue + if custom_dirs is not None: + # Allow multiple plugin directories to be specified + for pd_text in custom_dirs.split(','): + pd = Path(pd_text.strip()).absolute() - # Ensure the directory has an __init__.py file - init_filename = pd.joinpath('__init__.py') + # Attempt to create the directory if it does not already exist + if not pd.exists(): + try: + pd.mkdir(exist_ok=True) + except Exception: # pragma: no cover + logger.error(f"Could not create plugin directory '{pd}'") + continue - if not init_filename.exists(): - try: - init_filename.write_text("# InvenTree plugin directory\n") - except Exception: # pragma: no cover - logger.error(f"Could not create file '{init_filename}'") - continue + # Ensure the directory has an __init__.py file + init_filename = pd.joinpath('__init__.py') - # By this point, we have confirmed that the directory at least exists - if pd.exists() and pd.is_dir(): - # Convert to python dot-path - if pd.is_relative_to(settings.BASE_DIR): - pd_path = '.'.join(pd.relative_to(settings.BASE_DIR).parts) - else: - pd_path = str(pd) + if not init_filename.exists(): + try: + init_filename.write_text("# InvenTree plugin directory\n") + except Exception: # pragma: no cover + logger.error(f"Could not create file '{init_filename}'") + continue - # Add path - dirs.append(pd_path) - logger.info(f"Added plugin directory: '{pd}' as '{pd_path}'") + # By this point, we have confirmed that the directory at least exists + if pd.exists() and pd.is_dir(): + # Convert to python dot-path + if pd.is_relative_to(settings.BASE_DIR): + pd_path = '.'.join(pd.relative_to(settings.BASE_DIR).parts) + else: + pd_path = str(pd) + + # Add path + dirs.append(pd_path) + logger.info(f"Added plugin directory: '{pd}' as '{pd_path}'") return dirs def collect_plugins(self): """Collect plugins from all possible ways of loading. Returned as list.""" - if not settings.PLUGINS_ENABLED: - # Plugins not enabled, do nothing - return # pragma: no cover collected_plugins = [] @@ -293,17 +289,20 @@ class PluginsRegistry: if modules: [collected_plugins.append(item) for item in modules] - # Check if not running in testing mode and apps should be loaded from hooks - if (not settings.PLUGIN_TESTING) or (settings.PLUGIN_TESTING and settings.PLUGIN_TESTING_SETUP): - # Collect plugins from setup entry points - for entry in get_entrypoints(): - try: - plugin = entry.load() - plugin.is_package = True - plugin._get_package_metadata() - collected_plugins.append(plugin) - except Exception as error: # pragma: no cover - handle_error(error, do_raise=False, log_name='discovery') + # From this point any plugins are considered "external" and only loaded if plugins are explicitly enabled + if settings.PLUGINS_ENABLED: + + # Check if not running in testing mode and apps should be loaded from hooks + if (not settings.PLUGIN_TESTING) or (settings.PLUGIN_TESTING and settings.PLUGIN_TESTING_SETUP): + # Collect plugins from setup entry points + for entry in get_entrypoints(): + try: + plugin = entry.load() + plugin.is_package = True + plugin._get_package_metadata() + collected_plugins.append(plugin) + except Exception as error: # pragma: no cover + handle_error(error, do_raise=False, log_name='discovery') # Log collected plugins logger.info(f'Collected {len(collected_plugins)} plugins!') @@ -335,7 +334,7 @@ class PluginsRegistry: # endregion # region registry functions - def with_mixin(self, mixin: str, active=None): + def with_mixin(self, mixin: str, active=None, builtin=None): """Returns reference to all plugins that have a specified mixin enabled.""" result = [] @@ -343,10 +342,13 @@ class PluginsRegistry: if plugin.mixin_enabled(mixin): if active is not None: - # Filter by 'enabled' status - config = plugin.plugin_config() + # Filter by 'active' status of plugin + if active != plugin.is_active(): + continue - if config.active != active: + if builtin is not None: + # Filter by 'builtin' status of plugin + if builtin != plugin.is_builtin: continue result.append(plugin) @@ -403,8 +405,14 @@ class PluginsRegistry: # Append reference to plugin plg.db = plg_db - # Always activate if testing - if settings.PLUGIN_TESTING or (plg_db and plg_db.active): + # Check if this is a 'builtin' plugin + builtin = plg.check_is_builtin() + + # Determine if this plugin should be loaded: + # - If PLUGIN_TESTING is enabled + # - If this is a 'builtin' plugin + # - If this plugin has been explicitly enabled by the user + if settings.PLUGIN_TESTING or builtin or (plg_db and plg_db.active): # Check if the plugin was blocked -> threw an error; option1: package, option2: file-based if disabled and ((plg.__name__ == disabled) or (plg.__module__ == disabled)): safe_reference(plugin=plg, key=plg_key, active=False) @@ -498,10 +506,9 @@ class PluginsRegistry: for _key, plugin in plugins: if plugin.mixin_enabled('schedule'): - config = plugin.plugin_config() - # Only active tasks for plugins which are enabled - if config and config.active: + if plugin.is_active(): + # Only active tasks for plugins which are enabled plugin.register_tasks() task_keys += plugin.get_task_names() diff --git a/InvenTree/plugin/builtin/action/simpleactionplugin.py b/InvenTree/plugin/samples/integration/simpleactionplugin.py similarity index 100% rename from InvenTree/plugin/builtin/action/simpleactionplugin.py rename to InvenTree/plugin/samples/integration/simpleactionplugin.py diff --git a/InvenTree/plugin/builtin/action/test_simpleactionplugin.py b/InvenTree/plugin/samples/integration/test_simpleactionplugin.py similarity index 93% rename from InvenTree/plugin/builtin/action/test_simpleactionplugin.py rename to InvenTree/plugin/samples/integration/test_simpleactionplugin.py index 6bc2329496..af5159458e 100644 --- a/InvenTree/plugin/builtin/action/test_simpleactionplugin.py +++ b/InvenTree/plugin/samples/integration/test_simpleactionplugin.py @@ -1,7 +1,7 @@ """Unit tests for action plugins.""" from InvenTree.helpers import InvenTreeTestCase -from plugin.builtin.action.simpleactionplugin import SimpleActionPlugin +from plugin.samples.integration.simpleactionplugin import SimpleActionPlugin class SimpleActionPluginTests(InvenTreeTestCase): diff --git a/InvenTree/plugin/test_api.py b/InvenTree/plugin/test_api.py index 192b5514e8..7630bb415c 100644 --- a/InvenTree/plugin/test_api.py +++ b/InvenTree/plugin/test_api.py @@ -28,25 +28,38 @@ class PluginDetailAPITest(PluginMixin, InvenTreeAPITestCase): url = reverse('api-plugin-install') # valid - Pypi - data = self.post(url, { - 'confirm': True, - 'packagename': self.PKG_NAME - }, expected_code=201).data + data = self.post( + url, + { + 'confirm': True, + 'packagename': self.PKG_NAME + }, + expected_code=201, + ).data + self.assertEqual(data['success'], True) # valid - github url - data = self.post(url, { - 'confirm': True, - 'url': self.PKG_URL - }, expected_code=201).data + data = self.post( + url, + { + 'confirm': True, + 'url': self.PKG_URL + }, + expected_code=201, + ).data self.assertEqual(data['success'], True) # valid - github url and packagename - data = self.post(url, { - 'confirm': True, - 'url': self.PKG_URL, - 'packagename': 'minimal', - }, expected_code=201).data + data = self.post( + url, + { + 'confirm': True, + 'url': self.PKG_URL, + 'packagename': 'minimal', + }, + expected_code=201, + ).data self.assertEqual(data['success'], True) # invalid tries @@ -57,17 +70,20 @@ class PluginDetailAPITest(PluginMixin, InvenTreeAPITestCase): data = self.post(url, { 'confirm': True, }, expected_code=400).data + self.assertEqual(data['url'][0].title().upper(), self.MSG_NO_PKG.upper()) self.assertEqual(data['packagename'][0].title().upper(), self.MSG_NO_PKG.upper()) # not confirmed self.post(url, { 'packagename': self.PKG_NAME - }, expected_code=400).data + }, expected_code=400) + data = self.post(url, { 'packagename': self.PKG_NAME, 'confirm': False, }, expected_code=400).data + self.assertEqual(data['confirm'][0].title().upper(), 'Installation not confirmed'.upper()) def test_admin_action(self): diff --git a/InvenTree/report/tests.py b/InvenTree/report/tests.py index 36e5482958..8361be45e8 100644 --- a/InvenTree/report/tests.py +++ b/InvenTree/report/tests.py @@ -305,6 +305,7 @@ class TestReportTest(ReportTest): InvenTreeSetting.set_setting('REPORT_ATTACH_TEST_REPORT', True, None) response = self.get(url, {'item': item.pk}, expected_code=200) + headers = response.headers self.assertEqual(headers['Content-Type'], 'application/pdf') diff --git a/InvenTree/templates/InvenTree/settings/plugin.html b/InvenTree/templates/InvenTree/settings/plugin.html index 8051563d24..4776c9d1f0 100644 --- a/InvenTree/templates/InvenTree/settings/plugin.html +++ b/InvenTree/templates/InvenTree/settings/plugin.html @@ -31,6 +31,8 @@ +{% plugins_enabled as plug %} +
{% trans "Admin" %} | {% trans "Name" %} | +{% trans "Key" %} | {% trans "Author" %} | {% trans "Date" %} | {% trans "Version" %} | +|
---|---|---|---|---|---|---|
{% trans 'Active plugins' %} | ||||||
- {% if user.is_staff and perms.plugin.change_pluginconfig %} - {% url 'admin:plugin_pluginconfig_change' plugin.pk as url %} - {% include "admin_button.html" with url=url %} - {% endif %} - | -{{ plugin.human_name }} - {{plugin_key}} - {% define plugin.registered_mixins as mixin_list %} - - {% if plugin.is_sample %} - - {% trans "Sample" %} - - {% endif %} - - {% if mixin_list %} - {% for mixin in mixin_list %} - - {{ mixin.human_name }} - - {% endfor %} - {% endif %} - - {% if plugin.website %} - - {% endif %} - | -{{ plugin.author }} | -{% render_date plugin.pub_date %} | -{% if plugin.version %}{{ plugin.version }}{% endif %} | -||
{% trans 'Inactive plugins' %} | ||||||
{% trans 'Inactive plugins' %} | ||||||
- {% if user.is_staff and perms.plugin.change_pluginconfig %} - {% url 'admin:plugin_pluginconfig_change' plugin.pk as url %} - {% include "admin_button.html" with url=url %} - {% endif %} - | -{{plugin.name}} - {{plugin.key}} | -- | ||||
+ {% if plugin.is_active %} + + {% else %} + + {% endif %} + + {% if plugin.human_name %} + {{ plugin.human_name }} + {% elif plugin.title %} + {{ plugin.title }} + {% elif plugin.name %} + {{ plugin.name }} + {% endif %} + + {% define plugin.registered_mixins as mixin_list %} + + {% if mixin_list %} + {% for mixin in mixin_list %} + + {{ mixin.human_name }} + + {% endfor %} + {% endif %} + + {% if plugin.is_builtin %} + + {% trans "Builtin" %} + + {% endif %} + + {% if plugin.is_sample %} + + {% trans "Sample" %} + + {% endif %} + + {% if plugin.website %} + + {% endif %} + | +{{ plugin_key }} | + {% trans "Unvailable" as no_info %} ++ {% if plugin.author %} + {{ plugin.author }} + {% else %} + {{ no_info }} + {% endif %} + | ++ {% if plugin.pub_date %} + {% render_date plugin.pub_date %} + {% else %} + {{ no_info }} + {% endif %} + | ++ {% if plugin.version %} + {{ plugin.version }} + {% else %} + {{ no_info }} + {% endif %} + | ++ {% if user.is_staff and perms.plugin.change_pluginconfig %} + {% url 'admin:plugin_pluginconfig_change' plugin.pk as url %} + {% include "admin_button.html" with url=url %} + {% endif %} + | +{% trans "Name" %} | {{ plugin.human_name }}{% include "clip.html" %} | -
- | {% trans "Author" %} | -{{ plugin.author }}{% include "clip.html" %} | -||||
{% trans "Description" %} | {{ plugin.description }}{% include "clip.html" %} | |||||
+ | {% trans "Author" %} | +{{ plugin.author }}{% include "clip.html" %} | +||||
{% trans "Date" %} | @@ -94,7 +94,14 @@{% trans "Installation path" %} | {{ plugin.package_path }} | ||||
+ | {% trans "Builtin" %} | +{% trans "This is a builtin plugin which cannot be disabled" %} | +||||
{% trans "Commit Author" %} | {{ plugin.package.author }} - {{ plugin.package.mail }}{% include "clip.html" %} | diff --git a/InvenTree/templates/InvenTree/settings/settings.html b/InvenTree/templates/InvenTree/settings/settings.html index f6d1e990ab..79862291ae 100644 --- a/InvenTree/templates/InvenTree/settings/settings.html +++ b/InvenTree/templates/InvenTree/settings/settings.html @@ -42,8 +42,6 @@ {% include "InvenTree/settings/po.html" %} {% include "InvenTree/settings/so.html" %} -{% plugins_enabled as plug %} -{% if plug %} {% include "InvenTree/settings/plugin.html" %} {% plugin_list as pl_list %} {% for plugin_key, plugin in pl_list.items %} @@ -51,7 +49,6 @@ {% include "InvenTree/settings/plugin_settings.html" %} {% endif %} {% endfor %} -{% endif %} {% endif %} diff --git a/InvenTree/templates/InvenTree/settings/sidebar.html b/InvenTree/templates/InvenTree/settings/sidebar.html index f372224fc4..bc9a4f441e 100644 --- a/InvenTree/templates/InvenTree/settings/sidebar.html +++ b/InvenTree/templates/InvenTree/settings/sidebar.html @@ -51,8 +51,7 @@ {% trans "Sales Orders" as text %} {% include "sidebar_item.html" with label='sales-order' text=text icon="fa-truck" %} -{% plugins_enabled as plug %} -{% if plug %} + {% trans "Plugin Settings" as text %} {% include "sidebar_header.html" with text=text %} {% trans "Plugins" as text %} @@ -64,6 +63,5 @@ {% include "sidebar_item.html" with label='plugin-'|add:plugin_key text=plugin.human_name %} {% endif %} {% endfor %} -{% endif %} {% endif %} diff --git a/InvenTree/templates/admin_button.html b/InvenTree/templates/admin_button.html index 221d7f6c8e..abc3d2d91c 100644 --- a/InvenTree/templates/admin_button.html +++ b/InvenTree/templates/admin_button.html @@ -1,4 +1,12 @@ +{% load inventree_extras %} {% load i18n %} - + +{% inventree_customize 'hide_admin_link' as hidden %} + +{% if not hidden and user.is_staff %} + + + +{% endif %}