From 1f8f7fc163ff5fc3f43b73dd377e98675b730690 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Thu, 2 Apr 2026 00:26:28 +0200 Subject: [PATCH 01/13] Unable to install plugins through web UI using custom registry and/or Python wheel files. Fixes #11460 --- src/backend/InvenTree/plugin/installer.py | 37 ++++++++++++++--------- 1 file changed, 23 insertions(+), 14 deletions(-) diff --git a/src/backend/InvenTree/plugin/installer.py b/src/backend/InvenTree/plugin/installer.py index b1fb2de03f9f..1f6b3c86eea0 100644 --- a/src/backend/InvenTree/plugin/installer.py +++ b/src/backend/InvenTree/plugin/installer.py @@ -245,9 +245,7 @@ def install_plugin(url=None, packagename=None, user=None, version=None): logger.info('install_plugin: %s, %s', url, packagename) # build up the command - install_name = ['install', '-U', '--disable-pip-version-check'] - - full_pkg = '' + full_pkg = None if url: # use custom registration / VCS @@ -255,27 +253,34 @@ def install_plugin(url=None, packagename=None, user=None, version=None): identifier in url for identifier in ['git+https', 'hg+https', 'svn+svn'] ]: # using a VCS provider - full_pkg = f'{packagename}@{url}' if packagename else url - elif url: - install_name.append('-i') - full_pkg = url - elif packagename: - full_pkg = packagename + full_pkg = [f'{packagename}@{url}' if packagename else url] + elif url and packagename: + full_pkg = [packagename, '-i', url] + else: + full_pkg = ['-i', url] elif packagename: # use pypi - full_pkg = packagename + full_pkg = [packagename] if version: - full_pkg = f'{full_pkg}=={version}' + full_pkg = [f'{packagename}=={version}'] - install_name.append(full_pkg) + if not full_pkg: + raise ValidationError( + _('No package name or URL could be generated with the inputs provided') + ) ret = {} # Execute installation via pip try: - result = pip_command(*install_name) + result = pip_command(*[ + 'install', + '-U', + '--disable-pip-version-check', + *full_pkg, + ]) ret['result'] = ret['success'] = _('Installed plugin successfully') ret['output'] = str(result, 'utf-8') @@ -292,7 +297,11 @@ def install_plugin(url=None, packagename=None, user=None, version=None): if version := ret.get('version'): # Save plugin to plugins file - update_plugins_file(packagename, full_package=full_pkg, version=version) + update_plugins_file( + packagename, + full_package=' '.join(full_pkg) if isinstance(full_pkg, list) else None, + version=version, + ) # Reload the plugin registry, to discover the new plugin from plugin.registry import registry From 92e81e3e67f9007f9d8e0e34f267c00599f8e050 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Thu, 2 Apr 2026 00:36:36 +0200 Subject: [PATCH 02/13] use more fitting variable name --- src/backend/InvenTree/plugin/installer.py | 26 ++++++++++++----------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/src/backend/InvenTree/plugin/installer.py b/src/backend/InvenTree/plugin/installer.py index 1f6b3c86eea0..e62ce541ea04 100644 --- a/src/backend/InvenTree/plugin/installer.py +++ b/src/backend/InvenTree/plugin/installer.py @@ -158,7 +158,7 @@ def install_plugins_file(): return True -def update_plugins_file(install_name, full_package=None, version=None, remove=False): +def update_plugins_file(install_name, install_ref=None, version=None, remove=False): """Add a plugin to the plugins file.""" if remove: logger.info('Removing plugin from plugins file: %s', install_name) @@ -166,8 +166,8 @@ def update_plugins_file(install_name, full_package=None, version=None, remove=Fa logger.info('Adding plugin to plugins file: %s', install_name) # If a full package name is provided, use that instead - if full_package and full_package != install_name: - new_value = full_package + if install_ref and install_ref != install_name: + new_value = install_ref else: new_value = f'{install_name}=={version}' if version else install_name @@ -245,7 +245,7 @@ def install_plugin(url=None, packagename=None, user=None, version=None): logger.info('install_plugin: %s, %s', url, packagename) # build up the command - full_pkg = None + install_ref = None if url: # use custom registration / VCS @@ -253,20 +253,20 @@ def install_plugin(url=None, packagename=None, user=None, version=None): identifier in url for identifier in ['git+https', 'hg+https', 'svn+svn'] ]: # using a VCS provider - full_pkg = [f'{packagename}@{url}' if packagename else url] + install_ref = [f'{packagename}@{url}' if packagename else url] elif url and packagename: - full_pkg = [packagename, '-i', url] + install_ref = [packagename, '-i', url] else: - full_pkg = ['-i', url] + install_ref = ['-i', url] elif packagename: # use pypi - full_pkg = [packagename] + install_ref = [packagename] if version: - full_pkg = [f'{packagename}=={version}'] + install_ref = [f'{packagename}=={version}'] - if not full_pkg: + if not install_ref: raise ValidationError( _('No package name or URL could be generated with the inputs provided') ) @@ -279,7 +279,7 @@ def install_plugin(url=None, packagename=None, user=None, version=None): 'install', '-U', '--disable-pip-version-check', - *full_pkg, + *install_ref, ]) ret['result'] = ret['success'] = _('Installed plugin successfully') @@ -299,7 +299,9 @@ def install_plugin(url=None, packagename=None, user=None, version=None): # Save plugin to plugins file update_plugins_file( packagename, - full_package=' '.join(full_pkg) if isinstance(full_pkg, list) else None, + install_ref=' '.join(install_ref) + if isinstance(install_ref, list) + else None, version=version, ) From 4f695c32f0c6c90a43a5497c709d64de6faf7975 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Fri, 3 Apr 2026 01:22:36 +0200 Subject: [PATCH 03/13] add typing --- src/backend/InvenTree/plugin/installer.py | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/src/backend/InvenTree/plugin/installer.py b/src/backend/InvenTree/plugin/installer.py index e62ce541ea04..565753546b4b 100644 --- a/src/backend/InvenTree/plugin/installer.py +++ b/src/backend/InvenTree/plugin/installer.py @@ -3,6 +3,7 @@ import re import subprocess import sys +from typing import Optional from django.conf import settings from django.core.exceptions import ValidationError @@ -158,7 +159,12 @@ def install_plugins_file(): return True -def update_plugins_file(install_name, install_ref=None, version=None, remove=False): +def update_plugins_file( + install_name: str, + full_package: Optional[str] = None, + version: Optional[str] = None, + remove: bool = False, +): """Add a plugin to the plugins file.""" if remove: logger.info('Removing plugin from plugins file: %s', install_name) @@ -166,8 +172,8 @@ def update_plugins_file(install_name, install_ref=None, version=None, remove=Fal logger.info('Adding plugin to plugins file: %s', install_name) # If a full package name is provided, use that instead - if install_ref and install_ref != install_name: - new_value = install_ref + if full_package and full_package != install_name: + new_value = full_package else: new_value = f'{install_name}=={version}' if version else install_name @@ -227,7 +233,12 @@ def compare_line(line: str): log_error('update_plugins_file', scope='plugins') -def install_plugin(url=None, packagename=None, user=None, version=None): +def install_plugin( + url: Optional[str] = None, + packagename: Optional[str] = None, + user=None, + version: Optional[str] = None, +): """Install a plugin into the python virtual environment. Args: From a366a5f961bdb68ad3afa12b0db8aa96e41a5c87 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Fri, 3 Apr 2026 01:31:34 +0200 Subject: [PATCH 04/13] clean up logic flow - make easier to understand --- src/backend/InvenTree/plugin/installer.py | 75 ++++++++++++----------- 1 file changed, 38 insertions(+), 37 deletions(-) diff --git a/src/backend/InvenTree/plugin/installer.py b/src/backend/InvenTree/plugin/installer.py index 565753546b4b..ef689cf3171e 100644 --- a/src/backend/InvenTree/plugin/installer.py +++ b/src/backend/InvenTree/plugin/installer.py @@ -160,22 +160,26 @@ def install_plugins_file(): def update_plugins_file( - install_name: str, - full_package: Optional[str] = None, + install_name: Optional[str], + install_reference: Optional[str] = None, version: Optional[str] = None, remove: bool = False, ): """Add a plugin to the plugins file.""" - if remove: - logger.info('Removing plugin from plugins file: %s', install_name) + # If a full package name is provided, use that instead + if install_reference and install_reference != install_name: + package_reference = install_reference else: - logger.info('Adding plugin to plugins file: %s', install_name) + package_reference = f'{install_name}=={version}' if version else install_name - # If a full package name is provided, use that instead - if full_package and full_package != install_name: - new_value = full_package + if not package_reference: + logger.error('No package reference provided for plugin') + return + + if remove: + logger.info('Removing plugin from plugins file: %s', package_reference) else: - new_value = f'{install_name}=={version}' if version else install_name + logger.info('Adding plugin to plugins file: %s', package_reference) pf = settings.PLUGIN_FILE @@ -212,13 +216,13 @@ def compare_line(line: str): found = True if not remove: # Replace line with new install name - output.append(new_value) + output.append(package_reference) else: output.append(line) # Append plugin to file if not found and not remove: - output.append(new_value) + output.append(package_reference) # Write file back to disk try: @@ -256,42 +260,43 @@ def install_plugin( logger.info('install_plugin: %s, %s', url, packagename) # build up the command - install_ref = None + package_ref: Optional[str] = None + index_url = None if url: - # use custom registration / VCS + # VCS based install - this can just be a VCS reference if True in [ identifier in url for identifier in ['git+https', 'hg+https', 'svn+svn'] ]: # using a VCS provider - install_ref = [f'{packagename}@{url}' if packagename else url] - elif url and packagename: - install_ref = [packagename, '-i', url] + package_ref = f'{packagename}@{url}' if packagename else url + # http based index reference + elif url.startswith(('http://', 'https://')) and packagename: + package_ref = packagename + index_url = url + # Ignore url and just use default index + elif packagename: + package_ref = packagename else: - install_ref = ['-i', url] + raise ValidationError(_('Invalid URL and no package name provided')) elif packagename: - # use pypi - install_ref = [packagename] + # use default index - most often pypi + package_ref = packagename if version: - install_ref = [f'{packagename}=={version}'] + package_ref = f'{packagename}=={version}' + else: + raise ValidationError(_('No package name or URL provided')) - if not install_ref: - raise ValidationError( - _('No package name or URL could be generated with the inputs provided') - ) + # Execute installation via pip + cmd: list[str] = ['install', '-U', '--disable-pip-version-check'] + if index_url: + cmd += ['-i', index_url] ret = {} - - # Execute installation via pip try: - result = pip_command(*[ - 'install', - '-U', - '--disable-pip-version-check', - *install_ref, - ]) + result = pip_command(*cmd, package_ref) ret['result'] = ret['success'] = _('Installed plugin successfully') ret['output'] = str(result, 'utf-8') @@ -309,11 +314,7 @@ def install_plugin( if version := ret.get('version'): # Save plugin to plugins file update_plugins_file( - packagename, - install_ref=' '.join(install_ref) - if isinstance(install_ref, list) - else None, - version=version, + install_name=packagename, install_reference=package_ref, version=version ) # Reload the plugin registry, to discover the new plugin From 33b761fac33f191dfa7df98d5e04a36c0f9f3750 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Fri, 3 Apr 2026 01:33:02 +0200 Subject: [PATCH 05/13] make check easier to understand --- src/backend/InvenTree/plugin/installer.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/backend/InvenTree/plugin/installer.py b/src/backend/InvenTree/plugin/installer.py index ef689cf3171e..4069a4234af3 100644 --- a/src/backend/InvenTree/plugin/installer.py +++ b/src/backend/InvenTree/plugin/installer.py @@ -265,9 +265,7 @@ def install_plugin( if url: # VCS based install - this can just be a VCS reference - if True in [ - identifier in url for identifier in ['git+https', 'hg+https', 'svn+svn'] - ]: + if url.startswith(('git+https://', 'hg+https://', 'svn+svn://')): # using a VCS provider package_ref = f'{packagename}@{url}' if packagename else url # http based index reference From cda4a99f5d42eebacbe4837697fbae7f9fcb1f91 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Fri, 3 Apr 2026 01:40:15 +0200 Subject: [PATCH 06/13] reduce complexity --- src/backend/InvenTree/plugin/installer.py | 25 ++++------------------- 1 file changed, 4 insertions(+), 21 deletions(-) diff --git a/src/backend/InvenTree/plugin/installer.py b/src/backend/InvenTree/plugin/installer.py index 4069a4234af3..16f695d625a8 100644 --- a/src/backend/InvenTree/plugin/installer.py +++ b/src/backend/InvenTree/plugin/installer.py @@ -159,23 +159,8 @@ def install_plugins_file(): return True -def update_plugins_file( - install_name: Optional[str], - install_reference: Optional[str] = None, - version: Optional[str] = None, - remove: bool = False, -): +def update_plugins_file(package_reference: str, remove: bool = False): """Add a plugin to the plugins file.""" - # If a full package name is provided, use that instead - if install_reference and install_reference != install_name: - package_reference = install_reference - else: - package_reference = f'{install_name}=={version}' if version else install_name - - if not package_reference: - logger.error('No package reference provided for plugin') - return - if remove: logger.info('Removing plugin from plugins file: %s', package_reference) else: @@ -189,7 +174,7 @@ def update_plugins_file( def compare_line(line: str): """Check if a line in the file matches the installname.""" - return re.match(rf'^{install_name}[\s=@]', line.strip()) + return re.match(rf'^{package_reference}[\s=@]', line.strip()) # First, read in existing plugin file try: @@ -311,9 +296,7 @@ def install_plugin( if version := ret.get('version'): # Save plugin to plugins file - update_plugins_file( - install_name=packagename, install_reference=package_ref, version=version - ) + update_plugins_file(package_reference=package_ref) # Reload the plugin registry, to discover the new plugin from plugin.registry import registry @@ -400,7 +383,7 @@ def uninstall_plugin(cfg: plugin.models.PluginConfig, user=None, delete_config=T raise ValidationError(_('Plugin installation not found')) # Update the plugins file - update_plugins_file(package_name, remove=True) + update_plugins_file(package_reference=package_name, remove=True) if delete_config: logger.info('Deleting plugin configuration from database: %s', cfg.key) From dcc4b9e3651b1a6871d51641d981f6dc5d2adacd Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Fri, 3 Apr 2026 01:48:31 +0200 Subject: [PATCH 07/13] ensure user is tested --- src/backend/InvenTree/plugin/installer.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/backend/InvenTree/plugin/installer.py b/src/backend/InvenTree/plugin/installer.py index 16f695d625a8..96278f4efb68 100644 --- a/src/backend/InvenTree/plugin/installer.py +++ b/src/backend/InvenTree/plugin/installer.py @@ -223,20 +223,20 @@ def compare_line(line: str): def install_plugin( + user, url: Optional[str] = None, packagename: Optional[str] = None, - user=None, version: Optional[str] = None, ): """Install a plugin into the python virtual environment. Args: + user: user performing the installation packagename: Optional package name to install url: Optional URL to install from - user: Optional user performing the installation version: Optional version specifier """ - if user and not user.is_staff: + if not user or not user.is_staff: raise ValidationError(_('Only staff users can administer plugins')) if settings.PLUGINS_INSTALL_DISABLED: From 8ea9382e715b34f21d507c564745bd58185ba8e6 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Sat, 4 Apr 2026 15:43:48 +0200 Subject: [PATCH 08/13] add more tests --- src/backend/InvenTree/plugin/test_api.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/backend/InvenTree/plugin/test_api.py b/src/backend/InvenTree/plugin/test_api.py index ca6c3126708f..4b76d737f203 100644 --- a/src/backend/InvenTree/plugin/test_api.py +++ b/src/backend/InvenTree/plugin/test_api.py @@ -111,6 +111,29 @@ def test_plugin_install(self): ).data self.assertEqual(data['success'], 'Installed plugin successfully') + # valid - python repository url and package name + data = self.post( + url, + { + 'confirm': True, + 'url': 'https://git.invenhost.com/api/packages/invenhost-c1/pypi/simple/', + 'packagename': 'inventree-approval', + }, + expected_code=201, + max_query_count=450, + max_query_time=30, + ).data + self.assertEqual(data['success'], 'Installed plugin successfully') + + # valid (kindoff) - Pypi and onsense uri + data = self.post( + url, + {'confirm': True, 'packagename': self.PKG_NAME, 'url': 'lol://example.com'}, + expected_code=201, + max_query_time=30, + max_query_count=450, + ).data + # invalid tries # no input data = self.post(url, {}, expected_code=400).data From e8ff45b1437fcc100d71755ec0fa070d15f97796 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Sat, 4 Apr 2026 16:20:16 +0200 Subject: [PATCH 09/13] add user validation --- src/backend/InvenTree/plugin/test_api.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/backend/InvenTree/plugin/test_api.py b/src/backend/InvenTree/plugin/test_api.py index 4b76d737f203..41b686f66e63 100644 --- a/src/backend/InvenTree/plugin/test_api.py +++ b/src/backend/InvenTree/plugin/test_api.py @@ -171,6 +171,17 @@ def test_plugin_install(self): str(response.data['non_field_errors']), ) + # plugin - normal user should not be able to install plugins + self.user.is_staff = False + self.user.save() + self.post( + url, + {'confirm': True, 'packagename': self.PKG_NAME}, + expected_code=400, + max_query_time=30, + max_query_count=450, + ) + def test_plugin_deactivate_mandatory(self): """Test deactivating a mandatory plugin.""" self.user.is_superuser = True From d2fc514ba754ab09cd36686f9de651015c73ad5f Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Sat, 4 Apr 2026 16:21:56 +0200 Subject: [PATCH 10/13] move test --- src/backend/InvenTree/plugin/test_api.py | 38 +++++++++++++++--------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/src/backend/InvenTree/plugin/test_api.py b/src/backend/InvenTree/plugin/test_api.py index 41b686f66e63..82dbc824f1d9 100644 --- a/src/backend/InvenTree/plugin/test_api.py +++ b/src/backend/InvenTree/plugin/test_api.py @@ -111,20 +111,6 @@ def test_plugin_install(self): ).data self.assertEqual(data['success'], 'Installed plugin successfully') - # valid - python repository url and package name - data = self.post( - url, - { - 'confirm': True, - 'url': 'https://git.invenhost.com/api/packages/invenhost-c1/pypi/simple/', - 'packagename': 'inventree-approval', - }, - expected_code=201, - max_query_count=450, - max_query_time=30, - ).data - self.assertEqual(data['success'], 'Installed plugin successfully') - # valid (kindoff) - Pypi and onsense uri data = self.post( url, @@ -691,3 +677,27 @@ def test_full_process(self): # Successful uninstallation with self.assertRaises(PluginConfig.DoesNotExist): PluginConfig.objects.get(key=slug) + + def test_registry(self): + """Test install with a custom registry.""" + # install - python repository url and package name + data = self.post( + reverse('api-plugin-install'), + { + 'confirm': True, + 'url': 'https://git.invenhost.com/api/packages/invenhost-c1/pypi/simple/', + 'packagename': 'inventree-approval', + }, + expected_code=201, + max_query_count=450, + max_query_time=30, + ).data + self.assertEqual(data['success'], 'Installed plugin successfully') + + # and uninstall it again to clean up + response = self.patch( + reverse('api-plugin-uninstall', kwargs={'plugin': 'inventree-approval'}), + data={'delete_config': True}, + max_query_count=350, + ) + self.assertEqual(response.status_code, 200) From 217dbcb9ba2799e08e2527d54b10c8f2329d6551 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Sat, 4 Apr 2026 17:07:29 +0200 Subject: [PATCH 11/13] disable uninstall in test --- src/backend/InvenTree/plugin/test_api.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/backend/InvenTree/plugin/test_api.py b/src/backend/InvenTree/plugin/test_api.py index 82dbc824f1d9..a825b0626795 100644 --- a/src/backend/InvenTree/plugin/test_api.py +++ b/src/backend/InvenTree/plugin/test_api.py @@ -678,15 +678,18 @@ def test_full_process(self): with self.assertRaises(PluginConfig.DoesNotExist): PluginConfig.objects.get(key=slug) + @override_settings(PLUGIN_TESTING_SETUP=True) def test_registry(self): """Test install with a custom registry.""" + plrg_name = 'inventree-approval' + # install - python repository url and package name data = self.post( reverse('api-plugin-install'), { 'confirm': True, 'url': 'https://git.invenhost.com/api/packages/invenhost-c1/pypi/simple/', - 'packagename': 'inventree-approval', + 'packagename': plrg_name, }, expected_code=201, max_query_count=450, @@ -694,10 +697,10 @@ def test_registry(self): ).data self.assertEqual(data['success'], 'Installed plugin successfully') - # and uninstall it again to clean up - response = self.patch( - reverse('api-plugin-uninstall', kwargs={'plugin': 'inventree-approval'}), - data={'delete_config': True}, - max_query_count=350, - ) - self.assertEqual(response.status_code, 200) + # # and uninstall it again to clean up + # response = self.patch( + # reverse('api-plugin-uninstall', kwargs={'plugin': plrg_name}), + # data={'delete_config': True}, + # max_query_count=350, + # ) + # self.assertEqual(response.status_code, 200) From ecd7211864a094b787f9a27ef3b3862449a3fdfb Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Tue, 7 Apr 2026 00:52:02 +0200 Subject: [PATCH 12/13] Update test_api.py --- src/backend/InvenTree/plugin/test_api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/backend/InvenTree/plugin/test_api.py b/src/backend/InvenTree/plugin/test_api.py index a825b0626795..6dfb3d3f8f3c 100644 --- a/src/backend/InvenTree/plugin/test_api.py +++ b/src/backend/InvenTree/plugin/test_api.py @@ -681,7 +681,7 @@ def test_full_process(self): @override_settings(PLUGIN_TESTING_SETUP=True) def test_registry(self): """Test install with a custom registry.""" - plrg_name = 'inventree-approval' + plrg_name = 'inventree-dummy-app-plugin' # install - python repository url and package name data = self.post( From 8d10c3d310310fcda45217b118b9bfae174e462b Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Thu, 23 Apr 2026 00:09:37 +0200 Subject: [PATCH 13/13] style fix --- src/backend/InvenTree/plugin/installer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/backend/InvenTree/plugin/installer.py b/src/backend/InvenTree/plugin/installer.py index ba7232b47a11..9de9c1a18718 100644 --- a/src/backend/InvenTree/plugin/installer.py +++ b/src/backend/InvenTree/plugin/installer.py @@ -275,7 +275,7 @@ def install_plugin( # Sanitize the package name for installation if any(c in package_ref for c in ';&|`$()'): raise ValidationError(_('Invalid characters in package name or URL')) - + # Execute installation via pip cmd: list[str] = ['install', '-U', '--disable-pip-version-check'] if index_url: