diff --git a/InvenTree/plugin/installer.py b/InvenTree/plugin/installer.py new file mode 100644 index 0000000000..1bc2d15932 --- /dev/null +++ b/InvenTree/plugin/installer.py @@ -0,0 +1,227 @@ +"""Install a plugin into the python virtual environment""" + +import logging +import re +import subprocess +import sys + +from django.conf import settings +from django.core.exceptions import ValidationError +from django.utils.translation import gettext_lazy as _ + +logger = logging.getLogger('inventree') + + +def pip_command(*args): + """Build and run a pip command using using the current python executable + + returns: subprocess.check_output + throws: subprocess.CalledProcessError + """ + + python = sys.executable + + command = [python, '-m', 'pip'] + command.extend(args) + + command = [str(x) for x in command] + + logger.info("Running pip command: %s", ' '.join(command)) + + return subprocess.check_output( + command, + cwd=settings.BASE_DIR.parent, + stderr=subprocess.STDOUT, + ) + + +def check_package_path(packagename: str): + """Determine the install path of a particular package + + - If installed, return the installation path + - If not installed, return False + """ + + logger.debug("check_package_path: %s", packagename) + + # Remove version information + for c in '<>=! ': + packagename = packagename.split(c)[0] + + try: + result = pip_command('show', packagename) + + output = result.decode('utf-8').split("\n") + + for line in output: + # Check if line matches pattern "Location: ..." + match = re.match(r'^Location:\s+(.+)$', line.strip()) + + if match: + return match.group(1) + + except subprocess.CalledProcessError as error: + output = error.output.decode('utf-8') + logger.exception("Plugin lookup failed: %s", str(output)) + return False + + # If we get here, the package is not installed + return False + + +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 + + try: + pip_command('install', '-r', str(pf)) + except subprocess.CalledProcessError as error: + output = error.output.decode('utf-8') + logger.exception("Plugin file installation failed: %s", str(output)) + return False + except Exception as exc: + logger.exception("Plugin file installation failed: %s", exc) + return False + + # At this point, the plugins file has been installed + return True + + +def add_plugin_to_file(install_name): + """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 + + # 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 + + # Check if plugin is already in file + for line in lines: + if line.strip() == install_name: + logger.debug("Plugin already exists in file") + return + + # Append plugin to file + lines.append(f'{install_name}') + + # Write file back to disk + try: + with pf.open(mode='w') as f: + for line in lines: + 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): + """Install a plugin into the python virtual environment: + + - A staff user account is required + - We must detect that we are running within a virtual environment + """ + + if user and not user.is_staff: + raise ValidationError(_("Permission denied: only staff users can install plugins")) + + logger.debug("install_plugin: %s, %s", url, packagename) + + # Check if we are running in a virtual environment + # For now, just log a warning + in_venv = sys.prefix != sys.base_prefix + + if not in_venv: + logger.warning("InvenTree is not running in a virtual environment") + + # build up the command + install_name = ['install', '-U'] + + full_pkg = '' + + if url: + # use custom registration / VCS + if True in [identifier in url for identifier in ['git+https', 'hg+https', 'svn+svn', ]]: + # using a VCS provider + if packagename: + full_pkg = f'{packagename}@{url}' + else: + full_pkg = url + else: # pragma: no cover + # using a custom package repositories + # This is only for pypa compliant directory services (all current are tested above) + # and not covered by tests. + if url: + install_name.append('-i') + full_pkg = url + elif packagename: + full_pkg = packagename + + elif packagename: + # use pypi + full_pkg = packagename + + install_name.append(full_pkg) + + ret = {} + + # Execute installation via pip + try: + result = pip_command(*install_name) + + ret['result'] = ret['success'] = _("Installed plugin successfully") + ret['output'] = str(result, 'utf-8') + + if packagename: + if path := check_package_path(packagename): + # Override result information + ret['result'] = _(f"Installed plugin into {path}") + + except subprocess.CalledProcessError as error: + # If an error was thrown, we need to parse the output + + output = error.output.decode('utf-8') + logger.exception("Plugin installation failed: %s", str(output)) + + errors = [ + _("Plugin installation failed"), + ] + + for msg in output.split("\n"): + msg = msg.strip() + + if msg: + errors.append(msg) + + if len(errors) > 1: + raise ValidationError(errors) + else: + raise ValidationError(errors[0]) + + # Save plugin to plugins file + add_plugin_to_file(full_pkg) + + # Reload the plugin registry, to discover the new plugin + from plugin.registry import registry + registry.reload_plugins(full_reload=True, force_reload=True, collect=True) + + return ret diff --git a/InvenTree/plugin/registry.py b/InvenTree/plugin/registry.py index dba2e9e56e..4a1e2411ec 100644 --- a/InvenTree/plugin/registry.py +++ b/InvenTree/plugin/registry.py @@ -8,7 +8,6 @@ import imp import importlib import logging import os -import subprocess import time from pathlib import Path from typing import Any, Dict, List, OrderedDict @@ -351,18 +350,13 @@ class PluginsRegistry: logger.info('Plugin file was already checked') return True - try: - subprocess.check_output(['pip', 'install', '-U', '-r', settings.PLUGIN_FILE], cwd=settings.BASE_DIR.parent) - except subprocess.CalledProcessError as error: # pragma: no cover - logger.exception('Ran into error while trying to install plugins!\n%s', str(error)) - return False - except FileNotFoundError: # pragma: no cover - # System most likely does not have 'git' installed - return False + from plugin.installer import install_plugins_file - # do not run again - settings.PLUGIN_FILE_CHECKED = True - return 'first_run' + if install_plugins_file(): + settings.PLUGIN_FILE_CHECKED = True + return 'first_run' + else: + return False # endregion diff --git a/InvenTree/plugin/serializers.py b/InvenTree/plugin/serializers.py index 34ecd7fa66..604db53cb6 100644 --- a/InvenTree/plugin/serializers.py +++ b/InvenTree/plugin/serializers.py @@ -1,16 +1,11 @@ """JSON serializers for plugin app.""" -import subprocess - -from django.conf import settings from django.core.exceptions import ValidationError -from django.utils import timezone from django.utils.translation import gettext_lazy as _ from rest_framework import serializers from common.serializers import GenericReferencedSettingSerializer -from InvenTree.tasks import check_for_migrations, offload_task from plugin.models import NotificationUserSetting, PluginConfig, PluginSetting @@ -119,63 +114,15 @@ class PluginConfigInstallSerializer(serializers.Serializer): def save(self): """Install a plugin from a package registry and set operational results as instance data.""" + + from plugin.installer import install_plugin + data = self.validated_data packagename = data.get('packagename', '') url = data.get('url', '') - # build up the command - install_name = [] - - if url: - # use custom registration / VCS - if True in [identifier in url for identifier in ['git+https', 'hg+https', 'svn+svn', ]]: - # using a VCS provider - if packagename: - install_name.append(f'{packagename}@{url}') - else: - install_name.append(url) - else: # pragma: no cover - # using a custom package repositories - # This is only for pypa compliant directory services (all current are tested above) - # and not covered by tests. - install_name.append('-i') - install_name.append(url) - install_name.append(packagename) - - elif packagename: - # use pypi - install_name.append(packagename) - - command = 'python -m pip install'.split() - command.extend(install_name) - ret = {'command': ' '.join(command)} - success = False - # execute pypi - try: - result = subprocess.check_output(command, cwd=settings.BASE_DIR.parent) - ret['result'] = str(result, 'utf-8') - ret['success'] = True - success = True - except subprocess.CalledProcessError as error: # pragma: no cover - ret['result'] = str(error.output, 'utf-8') - ret['error'] = True - - # save plugin to plugin_file if installed successful - if success: - # Read content of plugin file - plg_lines = open(settings.PLUGIN_FILE).readlines() - with open(settings.PLUGIN_FILE, "a") as plugin_file: - # Check if last line has a newline - if plg_lines[-1][-1:] != '\n': - plugin_file.write('\n') - # Write new plugin to file - plugin_file.write(f'{" ".join(install_name)} # Installed {timezone.now()} by {str(self.context["request"].user)}\n') - - # Check for migrations - offload_task(check_for_migrations, worker=True) - - return ret + return install_plugin(url=url, packagename=packagename) class PluginConfigEmptySerializer(serializers.Serializer): diff --git a/InvenTree/plugin/test_api.py b/InvenTree/plugin/test_api.py index d9808eb164..ae0b4bb175 100644 --- a/InvenTree/plugin/test_api.py +++ b/InvenTree/plugin/test_api.py @@ -31,6 +31,16 @@ class PluginDetailAPITest(PluginMixin, InvenTreeAPITestCase): """Test the plugin install command.""" url = reverse('api-plugin-install') + # invalid package name + self.post( + url, + { + 'confirm': True, + 'packagename': 'invalid_package_name-asdads-asfd-asdf-asdf-asdf' + }, + expected_code=400 + ) + # valid - Pypi data = self.post( url, @@ -41,7 +51,7 @@ class PluginDetailAPITest(PluginMixin, InvenTreeAPITestCase): expected_code=201, ).data - self.assertEqual(data['success'], True) + self.assertEqual(data['success'], 'Installed plugin successfully') # valid - github url data = self.post( @@ -53,7 +63,7 @@ class PluginDetailAPITest(PluginMixin, InvenTreeAPITestCase): expected_code=201, ).data - self.assertEqual(data['success'], True) + self.assertEqual(data['success'], 'Installed plugin successfully') # valid - github url and package name data = self.post( @@ -65,7 +75,7 @@ class PluginDetailAPITest(PluginMixin, InvenTreeAPITestCase): }, expected_code=201, ).data - self.assertEqual(data['success'], True) + self.assertEqual(data['success'], 'Installed plugin successfully') # invalid tries # no input diff --git a/InvenTree/templates/js/translated/plugin.js b/InvenTree/templates/js/translated/plugin.js index 87b1b97c4d..378d23da70 100644 --- a/InvenTree/templates/js/translated/plugin.js +++ b/InvenTree/templates/js/translated/plugin.js @@ -157,6 +157,9 @@ function installPlugin() { onSuccess: function(data) { let msg = '{% trans "The Plugin was installed" %}'; showMessage(msg, {style: 'success', details: data.result, timeout: 30000}); + + // Reload the plugin table + $('#table-plugins').bootstrapTable('refresh'); } }); }