diff --git a/docs/docs/start/config.md b/docs/docs/start/config.md index 3596f4f000..5320939aa9 100644 --- a/docs/docs/start/config.md +++ b/docs/docs/start/config.md @@ -402,7 +402,5 @@ The following [plugin](../extend/plugins.md) configuration options are available | --- | --- | --- | --- | | INVENTREE_PLUGINS_ENABLED | plugins_enabled | Enable plugin support | False | | INVENTREE_PLUGIN_NOINSTALL | plugin_noinstall | Disable Plugin installation via API - only use plugins.txt file | False | +| INVENTREE_PLUGIN_FILE | plugins_plugin_file | Location of plugin installation file | *Not specified* | | INVENTREE_PLUGIN_DIR | plugins_plugin_dir | Location of external plugin directory | *Not specified* | - -!!! info "Plugins Directory" - If the `INVENTREE_PLUGIN_DIR` environment variable is not specified, the default location is the `plugins` directory within the InvenTree installation. diff --git a/src/backend/InvenTree/InvenTree/config.py b/src/backend/InvenTree/InvenTree/config.py index 5f1b8ee4f6..5c62fc66c6 100644 --- a/src/backend/InvenTree/InvenTree/config.py +++ b/src/backend/InvenTree/InvenTree/config.py @@ -283,6 +283,37 @@ def get_backup_dir(create=True): return bd +def get_plugin_file(): + """Returns the path of the InvenTree plugins specification file. + + Note: It will be created if it does not already exist! + """ + # Check if the plugin.txt file (specifying required plugins) is specified + plugin_file = get_setting('INVENTREE_PLUGIN_FILE', 'plugin_file') + + if not plugin_file: + # If not specified, look in the same directory as the configuration file + config_dir = get_config_file().parent + plugin_file = config_dir.joinpath('plugins.txt') + else: + # Make sure we are using a modern Path object + plugin_file = Path(plugin_file) + + if not plugin_file.exists(): + logger.warning( + 'Plugin configuration file does not exist - creating default file' + ) + logger.info("Creating plugin file at '%s'", plugin_file) + ensure_dir(plugin_file.parent) + + # If opening the file fails (no write permission, for example), then this will throw an error + plugin_file.write_text( + '# InvenTree Plugins (uses PIP framework to install)\n\n' + ) + + return plugin_file + + def get_plugin_dir(): """Returns the path of the custom plugins directory.""" return get_setting('INVENTREE_PLUGIN_DIR', 'plugin_dir') diff --git a/src/backend/InvenTree/InvenTree/settings.py b/src/backend/InvenTree/InvenTree/settings.py index 01d5b54805..7e294b8ac8 100644 --- a/src/backend/InvenTree/InvenTree/settings.py +++ b/src/backend/InvenTree/InvenTree/settings.py @@ -143,6 +143,8 @@ PLUGINS_INSTALL_DISABLED = get_boolean_setting( 'INVENTREE_PLUGIN_NOINSTALL', 'plugin_noinstall', False ) +PLUGIN_FILE = config.get_plugin_file() + # Plugin test settings PLUGIN_TESTING = get_setting( 'INVENTREE_PLUGIN_TESTING', 'PLUGIN_TESTING', TESTING @@ -158,6 +160,8 @@ PLUGIN_RETRY = get_setting( 'INVENTREE_PLUGIN_RETRY', 'PLUGIN_RETRY', 3, typecast=int ) # How often should plugin loading be tried? +PLUGIN_FILE_CHECKED = False # Was the plugin file checked? + STATICFILES_DIRS = [] # Translated Template settings diff --git a/src/backend/InvenTree/InvenTree/tests.py b/src/backend/InvenTree/InvenTree/tests.py index e0a1573b28..bf9ad84477 100644 --- a/src/backend/InvenTree/InvenTree/tests.py +++ b/src/backend/InvenTree/InvenTree/tests.py @@ -1180,6 +1180,23 @@ class TestSettings(InvenTreeTestCase): # make sure to clean up settings.TESTING_ENV = False + def test_initial_install(self): + """Test if install of plugins on startup works.""" + from plugin import registry + + if not settings.DOCKER: + # Check an install run + response = registry.install_plugin_file() + self.assertEqual(response, 'first_run') + + # Set dynamic setting to True and rerun to launch install + InvenTreeSetting.set_setting('PLUGIN_ON_STARTUP', True, self.user) + registry.reload_plugins(full_reload=True) + + # Check that there was another run + response = registry.install_plugin_file() + self.assertEqual(response, True) + def test_helpers_cfg_file(self): """Test get_config_file.""" # normal run - not configured @@ -1199,6 +1216,24 @@ class TestSettings(InvenTreeTestCase): str(config.get_config_file()).lower(), ) + def test_helpers_plugin_file(self): + """Test get_plugin_file.""" + # normal run - not configured + + valid = ['inventree/plugins.txt', 'inventree/data/plugins.txt'] + + self.assertTrue( + any(opt in str(config.get_plugin_file()).lower() for opt in valid) + ) + + # with env set + with self.in_env_context({ + 'INVENTREE_PLUGIN_FILE': '_testfolder/my_special_plugins.txt' + }): + self.assertIn( + '_testfolder/my_special_plugins.txt', str(config.get_plugin_file()) + ) + def test_helpers_setting(self): """Test get_setting.""" TEST_ENV_NAME = '123TEST' diff --git a/src/backend/InvenTree/config_template.yaml b/src/backend/InvenTree/config_template.yaml index 9dd5c3e2ba..934fa05d69 100644 --- a/src/backend/InvenTree/config_template.yaml +++ b/src/backend/InvenTree/config_template.yaml @@ -91,6 +91,7 @@ tracing: # Set this variable to True to enable InvenTree Plugins, or use the environment variable INVENTREE_PLUGINS_ENABLED plugins_enabled: False #plugin_noinstall: True +#plugin_file: '/path/to/plugins.txt' #plugin_dir: '/path/to/plugins/' # Set this variable to True to enable auto-migrations, or use the environment variable INVENTREE_AUTO_UPDATE diff --git a/src/backend/InvenTree/plugin/apps.py b/src/backend/InvenTree/plugin/apps.py index f0dd345561..fa7801d72e 100644 --- a/src/backend/InvenTree/plugin/apps.py +++ b/src/backend/InvenTree/plugin/apps.py @@ -40,6 +40,18 @@ class PluginAppConfig(AppConfig): 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 + # Perform a full reload of the plugin registry registry.reload_plugins( full_reload=True, force_reload=True, collect=True diff --git a/src/backend/InvenTree/plugin/helpers.py b/src/backend/InvenTree/plugin/helpers.py index 161ef678cc..9eee31bc39 100644 --- a/src/backend/InvenTree/plugin/helpers.py +++ b/src/backend/InvenTree/plugin/helpers.py @@ -177,37 +177,27 @@ def get_modules(pkg, path=None): elif type(path) is not list: path = [path] - try: - packages = pkgutil.walk_packages(path) - except Exception as e: - raise IntegrationPluginError(pkg.__name__, str(e)) + for finder, name, _ in pkgutil.walk_packages(path): + try: + if sys.version_info < (3, 12): + module = finder.find_module(name).load_module(name) + else: + spec = finder.find_spec(name) + module = module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + 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: # pragma: no cover + pass + except Exception as error: + # this 'protects' against malformed plugin modules by more or less silently failing - try: - for finder, name, _ in packages: - try: - if sys.version_info < (3, 12): - try: - module = finder.find_module(name).load_module(name) - except Exception as e: - raise IntegrationPluginError(name, str(e)) - - else: - spec = finder.find_spec(name) - module = module_from_spec(spec) - sys.modules[name] = module - spec.loader.exec_module(module) - 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: # pragma: no cover - 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') + # log to stack + log_error({name: str(error)}, 'discovery') return [v for k, v in context.items()] @@ -216,8 +206,8 @@ def get_classes(module) -> list: """Get all classes in a given module.""" try: return inspect.getmembers(module, inspect.isclass) - except Exception as e: - raise IntegrationPluginError(module.__name__, str(e)) + except Exception: + return [] def get_plugins(pkg, baseclass, path=None): @@ -233,12 +223,7 @@ def get_plugins(pkg, baseclass, path=None): # Iterate through each module in the package for mod in modules: # Iterate through each class in the module - try: - classes = get_classes(mod) - except IntegrationPluginError: - continue - - for item in classes: + for item in get_classes(mod): plugin = item[1] if issubclass(plugin, baseclass) and plugin.NAME: plugins.append(plugin) diff --git a/src/backend/InvenTree/plugin/installer.py b/src/backend/InvenTree/plugin/installer.py index 4551f29560..daf62cee96 100644 --- a/src/backend/InvenTree/plugin/installer.py +++ b/src/backend/InvenTree/plugin/installer.py @@ -155,6 +155,98 @@ def plugins_dir(): return pd.absolute() +def install_plugins_file(): + """Install plugins from the plugins file.""" + logger.info('Installing plugins from plugins file') + + pf = settings.PLUGIN_FILE + + if not pf or not pf.exists(): + logger.warning('Plugin file %s does not exist', str(pf)) + return + + plugin_dir = plugins_dir() + + cmd = ['install', '-U', '--target', str(plugin_dir), '-r', str(pf)] + + try: + pip_command(*cmd) + except subprocess.CalledProcessError as error: + output = error.output.decode('utf-8') + logger.exception('Plugin file installation failed: %s', str(output)) + log_error('pip') + return False + except Exception as exc: + logger.exception('Plugin file installation failed: %s', exc) + log_error('pip') + return False + + # Update static files + plugin.staticfiles.collect_plugins_static_files() + plugin.staticfiles.clear_plugins_static_files() + + # At this point, the plugins file has been installed + return True + + +def update_plugins_file(install_name, remove=False): + """Add a plugin to the plugins file.""" + logger.info('Adding plugin to plugins file: %s', install_name) + + pf = settings.PLUGIN_FILE + + if not pf or not pf.exists(): + logger.warning('Plugin file %s does not exist', str(pf)) + return + + def compare_line(line: str): + """Check if a line in the file matches the installname.""" + return line.strip().split('==')[0] == install_name.split('==')[0] + + # First, read in existing plugin file + try: + with pf.open(mode='r') as f: + lines = f.readlines() + except Exception as exc: + logger.exception('Failed to read plugins file: %s', str(exc)) + return + + # Reconstruct output file + output = [] + + found = False + + # Check if plugin is already in file + for line in lines: + # Ignore processing for any commented lines + if line.strip().startswith('#'): + output.append(line) + continue + + if compare_line(line): + found = True + if not remove: + # Replace line with new install name + output.append(install_name) + else: + output.append(line) + + # Append plugin to file + if not found and not remove: + output.append(install_name) + + # Write file back to disk + try: + with pf.open(mode='w') as f: + for line in output: + f.write(line) + + if not line.endswith('\n'): + f.write('\n') + except Exception as exc: + logger.exception('Failed to add plugin to plugins file: %s', str(exc)) + + def install_plugin(url=None, packagename=None, user=None, version=None): """Install a plugin into the python virtual environment. @@ -217,6 +309,9 @@ def install_plugin(url=None, packagename=None, user=None, version=None): except subprocess.CalledProcessError as error: handle_pip_error(error, 'plugin_install') + # Save plugin to plugins file + update_plugins_file(full_pkg) + # Reload the plugin registry, to discover the new plugin from plugin.registry import registry @@ -281,6 +376,9 @@ def uninstall_plugin(cfg: plugin.models.PluginConfig, user=None, delete_config=T # No matching install target found raise ValidationError(_('Plugin installation not found')) + # Update the plugins file + update_plugins_file(package_name, remove=True) + if delete_config: # Remove the plugin configuration from the database cfg.delete() diff --git a/src/backend/InvenTree/plugin/registry.py b/src/backend/InvenTree/plugin/registry.py index dac8ac05f4..4c29e06fea 100644 --- a/src/backend/InvenTree/plugin/registry.py +++ b/src/backend/InvenTree/plugin/registry.py @@ -428,6 +428,19 @@ class PluginsRegistry: self.mixin_modules = collected_mixins + def install_plugin_file(self): + """Make sure all plugins are installed in the current environment.""" + if settings.PLUGIN_FILE_CHECKED: + logger.info('Plugin file was already checked') + return True + + from plugin.installer import install_plugins_file + + if install_plugins_file(): + settings.PLUGIN_FILE_CHECKED = True + return 'first_run' + return False + # endregion # region general internal loading / activating / deactivating / unloading diff --git a/src/backend/InvenTree/templates/InvenTree/settings/plugin.html b/src/backend/InvenTree/templates/InvenTree/settings/plugin.html index 030c1e5224..f97c98795e 100644 --- a/src/backend/InvenTree/templates/InvenTree/settings/plugin.html +++ b/src/backend/InvenTree/templates/InvenTree/settings/plugin.html @@ -25,6 +25,7 @@ {% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_URL" icon="fa-link" %} {% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_NAVIGATION" icon="fa-sitemap" %} {% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_APP" icon="fa-rocket" %} + {% include "InvenTree/settings/setting.html" with key="PLUGIN_ON_STARTUP" %} </tbody> </table> </div>