From c0c4e9c226f68f103b82b00335a6c04dea10418f Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 7 Feb 2024 02:08:30 +1100 Subject: [PATCH] [WIP] Plugin Updates (#6400) * Add method to extract "install name" from a plugin * Include more information in plugin meta serializer * Implement better API filtering for PluginConfig list * Add an "update" button to the plugin table row actions - Only for "package" plugins * Adds method to update a plugin: - Add plugin.installer.update_plugin method - Add error logging to existing methods - Add API endpoint and serializer - Integrate into PUI table * Implement lazy loading for plugin tables * Extract package information on registry load - Info is already available via entrypoint data - Significantly faster as introspection operation is expensive - Less code is good code * Frontend updates * Add accordion to plugin page * Add setting to control periodic version check updates * Update API version info * Add "package_name" field to PluginConfig - When the plugin is loaded, save this name to the PluginConfig model - Update the admin view * Update API serializer * Refactor plugin installer code - Add common functions * Adds API endpoint for uninstalling an installed plugin * Allow uninstall of plugin via API - Add API endpoints - Add UI elements * Tweak for admin list display * Update plugin * Refactor "update" method - Just use the "install" function - Add optional "version" specifier - UI updates * Allow deletion of PluginConfig when uninstalling plugin * Add placeholder for deleting database tables * Revert code change - get_object() is required * Use registry.get_plugin() - Instead of registry.plugins.get() - get_plugin checks registry hash - performs registry reload if necessary * Add PluginValidationMixin class - Allows the entire model to be validated via plugins - Called on model.full_clean() - Called on model.save() * Update Validation sample plugin * Fix for InvenTreeTree models * Refactor build.models - Expose models to plugin validation * Update stock.models * Update more models - common.models - company.models * Update more models - label.models - order.models - part.models * More model updates * Update docs * Fix for potential plugin edge case - plugin slug is globally unique - do not use get_or_create with two lookup fields - will throw an IntegrityError if you change the name of a plugin * Inherit DiffMixin into PluginValidationMixin - Allows us to pass model diffs through to validation - Plugins can validate based on what has *changed* * Update documentation * Add get_plugin_config helper function * Bug fix * Bug fix * Update plugin hash when calling set_plugin_state * Working on unit testing * More unit testing * Fix typo (installing -> uninstalling) * Reduce default timeout * set default timeout as part of ApiDefaults * revert changes to launch.json * Remove delete_tables field - Will come back in a future PR * Fix display of nonFIeldErrors in ApiForm.tsx * Allow deletion of deleted plugins - PluginConfig which no longer matches a valid (installed) plugin * Cleanup * Move get_plugin_config into registry.py * Move extract_int into InvenTree.helpers * Fix log formatting * Update model definitions - Ensure there are no changes to the migrations * Update PluginErrorTable.tsx Remove unused var * Update PluginManagementPanel.tsx remove unused var * Comment out format line * Comment out format line * Fix access to get_plugin_config * Fix tests for SimpleActionPlugin * More unit test fixes * Update plugin/installer.py - Account for version string - Remove on uninstall * Fix --- InvenTree/InvenTree/api_version.py | 6 +- InvenTree/common/models.py | 6 + InvenTree/plugin/admin.py | 4 +- InvenTree/plugin/api.py | 179 ++++++++++------ InvenTree/plugin/installer.py | 168 ++++++++++++--- .../0008_pluginconfig_package_name.py | 18 ++ InvenTree/plugin/models.py | 18 ++ InvenTree/plugin/plugin.py | 24 +++ InvenTree/plugin/registry.py | 17 +- InvenTree/plugin/serializers.py | 40 +++- src/frontend/src/App.tsx | 1 + src/frontend/src/components/forms/ApiForm.tsx | 12 +- src/frontend/src/enums/ApiEndpoints.tsx | 2 + .../AdminCenter/PluginManagementPanel.tsx | 75 ++++--- .../src/tables/company/AddressTable.tsx | 4 +- .../src/tables/part/PartCategoryTable.tsx | 4 +- .../src/tables/part/PartTestTemplateTable.tsx | 4 +- .../src/tables/plugin/PluginErrorTable.tsx | 10 +- .../src/tables/plugin/PluginListTable.tsx | 192 ++++++++++++++---- .../src/tables/settings/CustomUnitsTable.tsx | 4 +- .../src/tables/settings/GroupTable.tsx | 4 +- .../src/tables/stock/StockLocationTable.tsx | 4 +- 22 files changed, 607 insertions(+), 189 deletions(-) create mode 100644 InvenTree/plugin/migrations/0008_pluginconfig_package_name.py diff --git a/InvenTree/InvenTree/api_version.py b/InvenTree/InvenTree/api_version.py index cfba12accd..38d08a7bf7 100644 --- a/InvenTree/InvenTree/api_version.py +++ b/InvenTree/InvenTree/api_version.py @@ -1,11 +1,15 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 165 +INVENTREE_API_VERSION = 166 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v166 -> 2024-02-04 : https://github.com/inventree/InvenTree/pull/6400 + - Adds package_name to plugin API + - Adds mechanism for uninstalling plugins via the API + v165 -> 2024-01-28 : https://github.com/inventree/InvenTree/pull/6040 - Adds supplier_part.name, part.creation_user, part.required_for_sales_order diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py index 2f0fe12fe1..7bde02d2f1 100644 --- a/InvenTree/common/models.py +++ b/InvenTree/common/models.py @@ -1864,6 +1864,12 @@ class InvenTreeSetting(BaseInvenTreeSetting): 'validator': bool, 'requires_restart': True, }, + 'PLUGIN_UPDATE_CHECK': { + 'name': _('Check for plugin updates'), + 'description': _('Enable periodic checks for updates to installed plugins'), + 'default': True, + 'validator': bool, + }, # Settings for plugin mixin features 'ENABLE_PLUGINS_URL': { 'name': _('Enable URL integration'), diff --git a/InvenTree/plugin/admin.py b/InvenTree/plugin/admin.py index 102a599da9..49afa1aed6 100644 --- a/InvenTree/plugin/admin.py +++ b/InvenTree/plugin/admin.py @@ -49,15 +49,15 @@ class PluginSettingInline(admin.TabularInline): class PluginConfigAdmin(admin.ModelAdmin): """Custom admin with restricted id fields.""" - readonly_fields = ['key', 'name'] + readonly_fields = ['key', 'name', 'package_name'] list_display = [ 'name', 'key', - '__str__', 'active', 'is_builtin', 'is_sample', 'is_installed', + 'is_package', ] list_filter = ['active'] actions = [plugin_activate, plugin_deactivate] diff --git a/InvenTree/plugin/api.py b/InvenTree/plugin/api.py index 97ca61aadc..a4cf80d497 100644 --- a/InvenTree/plugin/api.py +++ b/InvenTree/plugin/api.py @@ -1,7 +1,10 @@ """API for the plugin app.""" +from django.core.exceptions import ValidationError from django.urls import include, path, re_path +from django.utils.translation import gettext_lazy as _ +from django_filters import rest_framework as rest_filters from django_filters.rest_framework import DjangoFilterBackend from drf_spectacular.utils import extend_schema from rest_framework import permissions, status @@ -13,7 +16,6 @@ import plugin.serializers as PluginSerializers from common.api import GlobalSettingsPermissions from InvenTree.api import MetadataView from InvenTree.filters import SEARCH_ORDER_FILTER -from InvenTree.helpers import str2bool from InvenTree.mixins import ( CreateAPI, ListAPI, @@ -30,6 +32,87 @@ from plugin.models import PluginConfig, PluginSetting from plugin.plugin import InvenTreePlugin +class PluginFilter(rest_filters.FilterSet): + """Filter for the PluginConfig model. + + Provides custom filtering options for the FilterList API endpoint. + """ + + class Meta: + """Meta for the filter.""" + + model = PluginConfig + fields = ['active'] + + mixin = rest_filters.CharFilter( + field_name='mixin', method='filter_mixin', label='Mixin' + ) + + def filter_mixin(self, queryset, name, value): + """Filter by implement mixin. + + - A comma-separated list of mixin names can be provided. + - Only plugins which implement all of the provided mixins will be returned. + """ + matches = [] + mixins = [x.strip().lower() for x in value.split(',') if x] + + for result in queryset: + match = True + + for mixin in mixins: + if mixin not in result.mixins().keys(): + match = False + break + + if match: + matches.append(result.pk) + + return queryset.filter(pk__in=matches) + + builtin = rest_filters.BooleanFilter( + field_name='builtin', label='Builtin', method='filter_builtin' + ) + + def filter_builtin(self, queryset, name, value): + """Filter by 'builtin' flag.""" + matches = [] + + for result in queryset: + if result.is_builtin() == value: + matches.append(result.pk) + + return queryset.filter(pk__in=matches) + + sample = rest_filters.BooleanFilter( + field_name='sample', label='Sample', method='filter_sample' + ) + + def filter_sample(self, queryset, name, value): + """Filter by 'sample' flag.""" + matches = [] + + for result in queryset: + if result.is_sample() == value: + matches.append(result.pk) + + return queryset.filter(pk__in=matches) + + installed = rest_filters.BooleanFilter( + field_name='installed', label='Installed', method='filter_installed' + ) + + def filter_installed(self, queryset, name, value): + """Filter by 'installed' flag.""" + matches = [] + + for result in queryset: + if result.is_installed() == value: + matches.append(result.pk) + + return queryset.filter(pk__in=matches) + + class PluginList(ListAPI): """API endpoint for list of PluginConfig objects. @@ -41,70 +124,11 @@ class PluginList(ListAPI): # e.g. determining which label printing plugins are available permission_classes = [permissions.IsAuthenticated] + filterset_class = PluginFilter + serializer_class = PluginSerializers.PluginConfigSerializer queryset = PluginConfig.objects.all() - def filter_queryset(self, queryset): - """Filter for API requests. - - Filter by mixin with the `mixin` flag - """ - queryset = super().filter_queryset(queryset) - - params = self.request.query_params - - # Filter plugins which support a given mixin - mixin = params.get('mixin', None) - - if mixin: - matches = [] - - for result in queryset: - if mixin in result.mixins().keys(): - matches.append(result.pk) - - queryset = queryset.filter(pk__in=matches) - - # Filter queryset by 'builtin' flag - # We cannot do this using normal filters as it is not a database field - if 'builtin' in params: - builtin = str2bool(params['builtin']) - - matches = [] - - for result in queryset: - if result.is_builtin() == builtin: - matches.append(result.pk) - - queryset = queryset.filter(pk__in=matches) - - # Filter queryset by 'sample' flag - # We cannot do this using normal filters as it is not a database field - if 'sample' in params: - sample = str2bool(params['sample']) - - matches = [] - - for result in queryset: - if result.is_sample() == sample: - matches.append(result.pk) - - queryset = queryset.filter(pk__in=matches) - - # Filter queryset by 'installed' flag - if 'installed' in params: - installed = str2bool(params['installed']) - - matches = [] - - for result in queryset: - if result.is_installed() == installed: - matches.append(result.pk) - - queryset = queryset.filter(pk__in=matches) - - return queryset - filter_backends = SEARCH_ORDER_FILTER filterset_fields = ['active'] @@ -132,6 +156,20 @@ class PluginDetail(RetrieveUpdateDestroyAPI): queryset = PluginConfig.objects.all() serializer_class = PluginSerializers.PluginConfigSerializer + def delete(self, request, *args, **kwargs): + """Handle DELETE request for a PluginConfig instance. + + We only allow plugin deletion if the plugin is not active. + """ + cfg = self.get_object() + + if cfg.active: + raise ValidationError({ + 'detail': _('Plugin cannot be deleted as it is currently active') + }) + + return super().delete(request, *args, **kwargs) + class PluginInstall(CreateAPI): """Endpoint for installing a new plugin.""" @@ -156,6 +194,18 @@ class PluginInstall(CreateAPI): return serializer.save() +class PluginUninstall(UpdateAPI): + """Endpoint for uninstalling a single plugin.""" + + queryset = PluginConfig.objects.all() + serializer_class = PluginSerializers.PluginUninstallSerializer + permission_classes = [IsSuperuser] + + def perform_update(self, serializer): + """Uninstall the plugin.""" + serializer.save() + + class PluginActivate(UpdateAPI): """Endpoint for activating a plugin. @@ -398,6 +448,11 @@ plugin_api_urls = [ PluginActivate.as_view(), name='api-plugin-detail-activate', ), + path( + 'uninstall/', + PluginUninstall.as_view(), + name='api-plugin-uninstall', + ), path('', PluginDetail.as_view(), name='api-plugin-detail'), ]), ), diff --git a/InvenTree/plugin/installer.py b/InvenTree/plugin/installer.py index 057b4e612b..b7e7c772a2 100644 --- a/InvenTree/plugin/installer.py +++ b/InvenTree/plugin/installer.py @@ -9,6 +9,9 @@ from django.conf import settings from django.core.exceptions import ValidationError from django.utils.translation import gettext_lazy as _ +import plugin.models +from InvenTree.exceptions import log_error + logger = logging.getLogger('inventree') @@ -32,6 +35,33 @@ def pip_command(*args): ) +def handle_pip_error(error, path: str) -> list: + """Raise a ValidationError when the pip command fails. + + - Log the error to the database + - Format the output from a pip command into a list of error messages. + - Raise an appropriate error + """ + log_error(path) + + output = error.output.decode('utf-8') + + logger.error('Pip command failed: %s', output) + + errors = [] + + for line in output.split('\n'): + line = line.strip() + + if line: + errors.append(line) + + if len(errors) > 1: + raise ValidationError(errors) + else: + raise ValidationError(errors[0]) + + def check_package_path(packagename: str): """Determine the install path of a particular package. @@ -57,6 +87,8 @@ def check_package_path(packagename: str): return match.group(1) except subprocess.CalledProcessError as error: + log_error('check_package_path') + output = error.output.decode('utf-8') logger.exception('Plugin lookup failed: %s', str(output)) return False @@ -80,16 +112,18 @@ def install_plugins_file(): 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 # At this point, the plugins file has been installed return True -def add_plugin_to_file(install_name): +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) @@ -99,6 +133,10 @@ def add_plugin_to_file(install_name): 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: @@ -107,19 +145,34 @@ def add_plugin_to_file(install_name): 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: - if line.strip() == install_name: - logger.debug('Plugin already exists in file') - return + # 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 - lines.append(f'{install_name}') + 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 lines: + for line in output: f.write(line) if not line.endswith('\n'): @@ -128,19 +181,19 @@ def add_plugin_to_file(install_name): logger.exception('Failed to add plugin to plugins file: %s', str(exc)) -def install_plugin(url=None, packagename=None, user=None): +def install_plugin(url=None, packagename=None, user=None, version=None): """Install a plugin into the python virtual environment. - Rules: - - A staff user account is required - - We must detect that we are running within a virtual environment + Args: + 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: - raise ValidationError( - _('Permission denied: only staff users can install plugins') - ) + raise ValidationError(_('Only staff users can administer plugins')) - logger.debug('install_plugin: %s, %s', url, packagename) + logger.info('install_plugin: %s, %s', url, packagename) # Check if we are running in a virtual environment # For now, just log a warning @@ -178,6 +231,9 @@ def install_plugin(url=None, packagename=None, user=None): # use pypi full_pkg = packagename + if version: + full_pkg = f'{full_pkg}=={version}' + install_name.append(full_pkg) ret = {} @@ -195,26 +251,10 @@ def install_plugin(url=None, packagename=None, user=None): 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]) + handle_pip_error(error, 'plugin_install') # Save plugin to plugins file - add_plugin_to_file(full_pkg) + update_plugins_file(full_pkg) # Reload the plugin registry, to discover the new plugin from plugin.registry import registry @@ -222,3 +262,67 @@ def install_plugin(url=None, packagename=None, user=None): registry.reload_plugins(full_reload=True, force_reload=True, collect=True) return ret + + +def validate_package_plugin(cfg: plugin.models.PluginConfig, user=None): + """Validate a package plugin for update or removal.""" + if not cfg.plugin: + raise ValidationError(_('Plugin was not found in registry')) + + if not cfg.is_package(): + raise ValidationError(_('Plugin is not a packaged plugin')) + + if not cfg.package_name: + raise ValidationError(_('Plugin package name not found')) + + if user and not user.is_staff: + raise ValidationError(_('Only staff users can administer plugins')) + + +def uninstall_plugin(cfg: plugin.models.PluginConfig, user=None, delete_config=True): + """Uninstall a plugin from the python virtual environment. + + - The plugin must not be active + - The plugin must be a "package" and have a valid package name + + Args: + cfg: PluginConfig object + user: User performing the uninstall + delete_config: If True, delete the plugin configuration from the database + """ + from plugin.registry import registry + + if cfg.active: + raise ValidationError( + _('Plugin cannot be uninstalled as it is currently active') + ) + + validate_package_plugin(cfg, user) + package_name = cfg.package_name + logger.info('Uninstalling plugin: %s', package_name) + + cmd = ['uninstall', '-y', package_name] + + try: + result = pip_command(*cmd) + + ret = { + 'result': _('Uninstalled plugin successfully'), + 'success': True, + 'output': str(result, 'utf-8'), + } + + except subprocess.CalledProcessError as error: + handle_pip_error(error, 'plugin_uninstall') + + # Update the plugins file + update_plugins_file(package_name, remove=True) + + if delete_config: + # Remove the plugin configuration from the database + cfg.delete() + + # Reload the plugin registry + registry.reload_plugins(full_reload=True, force_reload=True, collect=True) + + return ret diff --git a/InvenTree/plugin/migrations/0008_pluginconfig_package_name.py b/InvenTree/plugin/migrations/0008_pluginconfig_package_name.py new file mode 100644 index 0000000000..b1d21e948d --- /dev/null +++ b/InvenTree/plugin/migrations/0008_pluginconfig_package_name.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.23 on 2024-02-04 11:18 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('plugin', '0007_auto_20230805_1748'), + ] + + operations = [ + migrations.AddField( + model_name='pluginconfig', + name='package_name', + field=models.CharField(blank=True, help_text='Name of the installed package, if the plugin was installed via PIP', max_length=255, null=True, verbose_name='Package Name'), + ), + ] diff --git a/InvenTree/plugin/models.py b/InvenTree/plugin/models.py index 83d76482ec..b77108e2e5 100644 --- a/InvenTree/plugin/models.py +++ b/InvenTree/plugin/models.py @@ -42,6 +42,16 @@ class PluginConfig(InvenTree.models.MetadataMixin, models.Model): help_text=_('PluginName of the plugin'), ) + package_name = models.CharField( + null=True, + blank=True, + max_length=255, + verbose_name=_('Package Name'), + help_text=_( + 'Name of the installed package, if the plugin was installed via PIP' + ), + ) + active = models.BooleanField( default=False, verbose_name=_('Active'), help_text=_('Is the plugin active') ) @@ -160,6 +170,14 @@ class PluginConfig(InvenTree.models.MetadataMixin, models.Model): return self.plugin.check_is_builtin() + @admin.display(boolean=True, description=_('Package Plugin')) + def is_package(self) -> bool: + """Return True if this is a 'package' plugin.""" + if not self.plugin: + return False + + return getattr(self.plugin, 'is_package', False) + class PluginSetting(common.models.BaseInvenTreeSetting): """This model represents settings for individual plugins.""" diff --git a/InvenTree/plugin/plugin.py b/InvenTree/plugin/plugin.py index f88f947dfc..339a2d4f01 100644 --- a/InvenTree/plugin/plugin.py +++ b/InvenTree/plugin/plugin.py @@ -337,6 +337,30 @@ class InvenTreePlugin(VersionMixin, MixinBase, MetaBase): """Path to the plugin.""" return self.check_package_path() + @classmethod + def check_package_install_name(cls) -> [str, None]: + """Installable package name of the plugin. + + e.g. if this plugin was installed via 'pip install ', + then this function should return '' + + Returns: + str: Install name of the package, else None + """ + return getattr(cls, 'package_name', None) + + @property + def package_install_name(self) -> [str, None]: + """Installable package name of the plugin. + + e.g. if this plugin was installed via 'pip install ', + then this function should return '' + + Returns: + str: Install name of the package, else None + """ + return self.check_package_install_name() + @property def settings_url(self): """URL to the settings panel for this plugin.""" diff --git a/InvenTree/plugin/registry.py b/InvenTree/plugin/registry.py index 29f4b2b634..835f32d5e7 100644 --- a/InvenTree/plugin/registry.py +++ b/InvenTree/plugin/registry.py @@ -439,8 +439,8 @@ class PluginsRegistry: raw_module = importlib.import_module(plugin) modules = get_plugins(raw_module, InvenTreePlugin, path=parent_path) - if modules: - [collected_plugins.append(item) for item in modules] + for item in modules or []: + collected_plugins.append(item) # From this point any plugins are considered "external" and only loaded if plugins are explicitly enabled if settings.PLUGINS_ENABLED: @@ -453,6 +453,7 @@ class PluginsRegistry: try: plugin = entry.load() plugin.is_package = True + plugin.package_name = getattr(entry.dist, 'name', None) plugin._get_package_metadata() collected_plugins.append(plugin) except Exception as error: # pragma: no cover @@ -549,11 +550,22 @@ class PluginsRegistry: # Check if this is a 'builtin' plugin builtin = plg.check_is_builtin() + package_name = None + + # Extract plugin package name + if getattr(plg, 'is_package', False): + package_name = getattr(plg, 'package_name', None) + # Auto-enable builtin plugins if builtin and plg_db and not plg_db.active: plg_db.active = True plg_db.save() + # Save the package_name attribute to the plugin + if plg_db.package_name != package_name: + plg_db.package_name = package_name + plg_db.save() + # Determine if this plugin should be loaded: # - If PLUGIN_TESTING is enabled # - If this is a 'builtin' plugin @@ -580,6 +592,7 @@ class PluginsRegistry: # Safe extra attributes plg_i.is_package = getattr(plg_i, 'is_package', False) + plg_i.pk = plg_db.pk if plg_db else None plg_i.db = plg_db diff --git a/InvenTree/plugin/serializers.py b/InvenTree/plugin/serializers.py index f1a8b6065b..19ae5b3a6e 100644 --- a/InvenTree/plugin/serializers.py +++ b/InvenTree/plugin/serializers.py @@ -51,12 +51,14 @@ class PluginConfigSerializer(serializers.ModelSerializer): 'pk', 'key', 'name', + 'package_name', 'active', 'meta', 'mixins', 'is_builtin', 'is_sample', 'is_installed', + 'is_package', ] read_only_fields = ['key', 'is_builtin', 'is_sample', 'is_installed'] @@ -81,6 +83,7 @@ class PluginConfigInstallSerializer(serializers.Serializer): 'Source for the package - this can be a custom registry or a VCS path' ), ) + packagename = serializers.CharField( required=False, allow_blank=True, @@ -89,6 +92,16 @@ class PluginConfigInstallSerializer(serializers.Serializer): 'Name for the Plugin Package - can also contain a version indicator' ), ) + + version = serializers.CharField( + required=False, + allow_blank=True, + label=_('Version'), + help_text=_( + 'Version specifier for the plugin. Leave blank for latest version.' + ), + ) + confirm = serializers.BooleanField( label=_('Confirm plugin installation'), help_text=_( @@ -120,8 +133,12 @@ class PluginConfigInstallSerializer(serializers.Serializer): packagename = data.get('packagename', '') url = data.get('url', '') + version = data.get('version', None) + user = self.context['request'].user - return install_plugin(url=url, packagename=packagename) + return install_plugin( + url=url, packagename=packagename, version=version, user=user + ) class PluginConfigEmptySerializer(serializers.Serializer): @@ -193,6 +210,27 @@ class PluginActivateSerializer(serializers.Serializer): return instance +class PluginUninstallSerializer(serializers.Serializer): + """Serializer for uninstalling a plugin.""" + + delete_config = serializers.BooleanField( + required=False, + default=True, + label=_('Delete configuration'), + help_text=_('Delete the plugin configuration from the database'), + ) + + def update(self, instance, validated_data): + """Uninstall the specified plugin.""" + from plugin.installer import uninstall_plugin + + return uninstall_plugin( + instance, + user=self.context['request'].user, + delete_config=validated_data.get('delete_config', True), + ) + + class PluginSettingSerializer(GenericReferencedSettingSerializer): """Serializer for the PluginSetting model.""" diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index a0710fdb2b..64ff7b1d93 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -21,6 +21,7 @@ export function setApiDefaults() { const token = useSessionState.getState().token; api.defaults.baseURL = host; + api.defaults.timeout = 1000; if (!!token) { api.defaults.headers.common['Authorization'] = `Token ${token}`; diff --git a/src/frontend/src/components/forms/ApiForm.tsx b/src/frontend/src/components/forms/ApiForm.tsx index a2f63933ad..2b07aabf9f 100644 --- a/src/frontend/src/components/forms/ApiForm.tsx +++ b/src/frontend/src/components/forms/ApiForm.tsx @@ -79,6 +79,7 @@ export interface ApiFormProps { onFormSuccess?: (data: any) => void; onFormError?: () => void; actions?: ApiFormAction[]; + timeout?: number; } export function OptionsApiForm({ @@ -296,6 +297,7 @@ export function ApiForm({ id, props }: { id: string; props: ApiFormProps }) { method: method, url: url, data: data, + timeout: props.timeout, headers: { 'Content-Type': hasFiles ? 'multipart/form-data' : 'application/json' } @@ -339,13 +341,15 @@ export function ApiForm({ id, props }: { id: string; props: ApiFormProps }) { switch (error.response.status) { case 400: // Data validation errors - const nonFieldErrors: string[] = []; + const _nonFieldErrors: string[] = []; const processErrors = (errors: any, _path?: string) => { for (const [k, v] of Object.entries(errors)) { const path = _path ? `${_path}.${k}` : k; - if (k === 'non_field_errors') { - nonFieldErrors.push((v as string[]).join(', ')); + if (k === 'non_field_errors' || k === '__all__') { + if (Array.isArray(v)) { + _nonFieldErrors.push(...v); + } continue; } @@ -358,7 +362,7 @@ export function ApiForm({ id, props }: { id: string; props: ApiFormProps }) { }; processErrors(error.response.data); - setNonFieldErrors(nonFieldErrors); + setNonFieldErrors(_nonFieldErrors); break; default: // Unexpected state on form error diff --git a/src/frontend/src/enums/ApiEndpoints.tsx b/src/frontend/src/enums/ApiEndpoints.tsx index d07d1c13c7..50838b7785 100644 --- a/src/frontend/src/enums/ApiEndpoints.tsx +++ b/src/frontend/src/enums/ApiEndpoints.tsx @@ -97,6 +97,8 @@ export enum ApiEndpoints { plugin_registry_status = 'plugins/status/', plugin_install = 'plugins/install/', plugin_reload = 'plugins/reload/', + plugin_activate = 'plugins/:id/activate/', + plugin_uninstall = 'plugins/:id/uninstall/', // Miscellaneous API endpoints error_report_list = 'error-report/', diff --git a/src/frontend/src/pages/Index/Settings/AdminCenter/PluginManagementPanel.tsx b/src/frontend/src/pages/Index/Settings/AdminCenter/PluginManagementPanel.tsx index 8052d1310d..0882f93ddb 100644 --- a/src/frontend/src/pages/Index/Settings/AdminCenter/PluginManagementPanel.tsx +++ b/src/frontend/src/pages/Index/Settings/AdminCenter/PluginManagementPanel.tsx @@ -1,11 +1,20 @@ -import { Trans } from '@lingui/macro'; -import { Alert, Stack, Title } from '@mantine/core'; +import { Trans, t } from '@lingui/macro'; +import { Accordion, Alert, Stack } from '@mantine/core'; import { IconInfoCircle } from '@tabler/icons-react'; +import { lazy } from 'react'; +import { StylishText } from '../../../../components/items/StylishText'; import { GlobalSettingList } from '../../../../components/settings/SettingList'; +import { Loadable } from '../../../../functions/loading'; import { useServerApiState } from '../../../../states/ApiState'; -import { PluginErrorTable } from '../../../../tables/plugin/PluginErrorTable'; -import { PluginListTable } from '../../../../tables/plugin/PluginListTable'; + +const PluginListTable = Loadable( + lazy(() => import('../../../../tables/plugin/PluginListTable')) +); + +const PluginErrorTable = Loadable( + lazy(() => import('../../../../tables/plugin/PluginErrorTable')) +); export default function PluginManagementPanel() { const pluginsEnabled = useServerApiState( @@ -26,30 +35,44 @@ export default function PluginManagementPanel() { )} - + + + + {t`Plugins`} + + + + + - - - <Trans>Plugin Error Stack</Trans> - - - + + + {t`Plugin Errors`} + + + + + - - - <Trans>Plugin Settings</Trans> - - - + + + {t`Plugin Settings`} + + + + + + ); } diff --git a/src/frontend/src/tables/company/AddressTable.tsx b/src/frontend/src/tables/company/AddressTable.tsx index af4732a0a1..d05bf2d028 100644 --- a/src/frontend/src/tables/company/AddressTable.tsx +++ b/src/frontend/src/tables/company/AddressTable.tsx @@ -127,9 +127,7 @@ export function AddressTable({ onFormSuccess: table.refreshTable }); - const [selectedAddress, setSelectedAddress] = useState( - undefined - ); + const [selectedAddress, setSelectedAddress] = useState(-1); const editAddress = useEditApiFormModal({ url: ApiEndpoints.address_list, diff --git a/src/frontend/src/tables/part/PartCategoryTable.tsx b/src/frontend/src/tables/part/PartCategoryTable.tsx index 914195ba14..a620e9c6cd 100644 --- a/src/frontend/src/tables/part/PartCategoryTable.tsx +++ b/src/frontend/src/tables/part/PartCategoryTable.tsx @@ -88,9 +88,7 @@ export function PartCategoryTable({ parentId }: { parentId?: any }) { } }); - const [selectedCategory, setSelectedCategory] = useState( - undefined - ); + const [selectedCategory, setSelectedCategory] = useState(-1); const editCategory = useEditApiFormModal({ url: ApiEndpoints.category_list, diff --git a/src/frontend/src/tables/part/PartTestTemplateTable.tsx b/src/frontend/src/tables/part/PartTestTemplateTable.tsx index 46ec95ed1a..513affad89 100644 --- a/src/frontend/src/tables/part/PartTestTemplateTable.tsx +++ b/src/frontend/src/tables/part/PartTestTemplateTable.tsx @@ -85,9 +85,7 @@ export default function PartTestTemplateTable({ partId }: { partId: number }) { onFormSuccess: table.refreshTable }); - const [selectedTest, setSelectedTest] = useState( - undefined - ); + const [selectedTest, setSelectedTest] = useState(-1); const editTestTemplate = useEditApiFormModal({ url: ApiEndpoints.part_test_template_list, diff --git a/src/frontend/src/tables/plugin/PluginErrorTable.tsx b/src/frontend/src/tables/plugin/PluginErrorTable.tsx index 96450dfd22..4d599c498c 100644 --- a/src/frontend/src/tables/plugin/PluginErrorTable.tsx +++ b/src/frontend/src/tables/plugin/PluginErrorTable.tsx @@ -6,7 +6,7 @@ import { ApiEndpoints } from '../../enums/ApiEndpoints'; import { useTable } from '../../hooks/UseTable'; import { apiUrl } from '../../states/ApiState'; import { TableColumn } from '../Column'; -import { InvenTreeTable, InvenTreeTableProps } from '../InvenTreeTable'; +import { InvenTreeTable } from '../InvenTreeTable'; export interface PluginRegistryErrorI { id: number; @@ -18,7 +18,7 @@ export interface PluginRegistryErrorI { /** * Table displaying list of plugin registry errors */ -export function PluginErrorTable({ props }: { props: InvenTreeTableProps }) { +export default function PluginErrorTable() { const table = useTable('registryErrors'); const registryErrorTableColumns: TableColumn[] = @@ -47,16 +47,12 @@ export function PluginErrorTable({ props }: { props: InvenTreeTableProps }) { tableState={table} columns={registryErrorTableColumns} props={{ - ...props, dataFormatter: (data: any) => data.registry_errors.map((e: any, i: number) => ({ id: i, ...e })), idAccessor: 'id', enableDownload: false, enableFilters: false, - enableSearch: false, - params: { - ...props.params - } + enableSearch: false }} /> ); diff --git a/src/frontend/src/tables/plugin/PluginListTable.tsx b/src/frontend/src/tables/plugin/PluginListTable.tsx index d2ef921613..52a406ff0b 100644 --- a/src/frontend/src/tables/plugin/PluginListTable.tsx +++ b/src/frontend/src/tables/plugin/PluginListTable.tsx @@ -16,11 +16,12 @@ import { IconCircleCheck, IconCircleX, IconHelpCircle, + IconInfoCircle, IconPlaylistAdd, IconRefresh } from '@tabler/icons-react'; import { IconDots } from '@tabler/icons-react'; -import { useCallback, useMemo } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { api } from '../../App'; @@ -35,13 +36,17 @@ import { DetailDrawer } from '../../components/nav/DetailDrawer'; import { PluginSettingList } from '../../components/settings/SettingList'; import { ApiEndpoints } from '../../enums/ApiEndpoints'; import { openEditApiForm } from '../../functions/forms'; -import { useCreateApiFormModal } from '../../hooks/UseForm'; +import { + useCreateApiFormModal, + useDeleteApiFormModal, + useEditApiFormModal +} from '../../hooks/UseForm'; import { useInstance } from '../../hooks/UseInstance'; import { useTable } from '../../hooks/UseTable'; import { apiUrl, useServerApiState } from '../../states/ApiState'; import { useUserState } from '../../states/UserState'; import { TableColumn } from '../Column'; -import { InvenTreeTable, InvenTreeTableProps } from '../InvenTreeTable'; +import { InvenTreeTable } from '../InvenTreeTable'; import { RowAction } from '../RowActions'; export interface PluginI { @@ -52,6 +57,8 @@ export interface PluginI { is_builtin: boolean; is_sample: boolean; is_installed: boolean; + is_package: boolean; + package_name: string | null; meta: { author: string | null; description: string | null; @@ -189,9 +196,16 @@ export function PluginDrawer({ Package information + {plugin?.is_package && ( + + )} + @@ -247,9 +266,10 @@ function PluginIcon(plugin: PluginI) { /** * Table displaying list of available plugins */ -export function PluginListTable({ props }: { props: InvenTreeTableProps }) { +export default function PluginListTable() { const table = useTable('plugin'); const navigate = useNavigate(); + const user = useUserState(); const pluginsEnabled = useServerApiState( (state) => state.server.plugins_enabled @@ -337,7 +357,7 @@ export function PluginListTable({ props }: { props: InvenTreeTableProps }) { confirm: t`Confirm` }, onConfirm: () => { - let url = apiUrl(ApiEndpoints.plugin_list, plugin_id) + 'activate/'; + let url = apiUrl(ApiEndpoints.plugin_activate, plugin_id); const id = 'plugin-activate'; @@ -349,7 +369,13 @@ export function PluginListTable({ props }: { props: InvenTreeTableProps }) { }); api - .patch(url, { active: active }) + .patch( + url, + { active: active }, + { + timeout: 30 * 1000 + } + ) .then(() => { table.refreshTable(); notifications.hide(id); @@ -376,42 +402,98 @@ export function PluginListTable({ props }: { props: InvenTreeTableProps }) { ); // Determine available actions for a given plugin - function rowActions(record: any): RowAction[] { - let actions: RowAction[] = []; + const rowActions = useCallback( + (record: any) => { + // TODO: Plugin actions should be updated based on on the users's permissions - if (!record.is_builtin && record.is_installed) { - if (record.active) { + let actions: RowAction[] = []; + + if (!record.is_builtin && record.is_installed) { + if (record.active) { + actions.push({ + title: t`Deactivate`, + color: 'red', + icon: , + onClick: () => { + activatePlugin(record.pk, record.name, false); + } + }); + } else { + actions.push({ + title: t`Activate`, + color: 'green', + icon: , + onClick: () => { + activatePlugin(record.pk, record.name, true); + } + }); + } + } + + // Active 'package' plugins can be updated + if (record.active && record.is_package && record.package_name) { actions.push({ - title: t`Deactivate`, - color: 'red', - icon: , + title: t`Update`, + color: 'blue', + icon: , onClick: () => { - activatePlugin(record.pk, record.name, false); - } - }); - } else { - actions.push({ - title: t`Activate`, - color: 'green', - icon: , - onClick: () => { - activatePlugin(record.pk, record.name, true); + setPluginPackage(record.package_name); + installPluginModal.open(); } }); } - } - return actions; - } + // Inactive 'package' plugins can be uninstalled + if ( + !record.active && + record.is_installed && + record.is_package && + record.package_name + ) { + actions.push({ + title: t`Uninstall`, + color: 'red', + icon: , + onClick: () => { + setSelectedPlugin(record.pk); + uninstallPluginModal.open(); + } + }); + } + + // Uninstalled 'package' plugins can be deleted + if (!record.is_installed) { + actions.push({ + title: t`Delete`, + color: 'red', + icon: , + onClick: () => { + setSelectedPlugin(record.pk); + deletePluginModal.open(); + } + }); + } + + return actions; + }, + [user, pluginsEnabled] + ); + + const [pluginPackage, setPluginPackage] = useState(''); const installPluginModal = useCreateApiFormModal({ title: t`Install plugin`, url: ApiEndpoints.plugin_install, + timeout: 30000, fields: { packagename: {}, url: {}, + version: {}, confirm: {} }, + initialData: { + packagename: pluginPackage + }, closeOnClickOutside: false, submitText: t`Install`, successMessage: undefined, @@ -427,7 +509,48 @@ export function PluginListTable({ props }: { props: InvenTreeTableProps }) { } }); - const user = useUserState(); + const [selectedPlugin, setSelectedPlugin] = useState(-1); + + const uninstallPluginModal = useEditApiFormModal({ + title: t`Uninstall Plugin`, + url: ApiEndpoints.plugin_uninstall, + pk: selectedPlugin, + fetchInitialData: false, + timeout: 30000, + fields: { + delete_config: {} + }, + preFormContent: ( + } + title={t`Confirm plugin uninstall`} + > + + {t`The selected plugin will be uninstalled.`} + {t`This action cannot be undone.`} + + + ), + onFormSuccess: (data) => { + notifications.show({ + title: t`Plugin uninstalled successfully`, + message: data.result, + autoClose: 30000, + color: 'green' + }); + + table.refreshTable(); + } + }); + + const deletePluginModal = useDeleteApiFormModal({ + url: ApiEndpoints.plugin_list, + pk: selectedPlugin, + title: t`Delete Plugin`, + onFormSuccess: table.refreshTable, + preFormWarning: t`Deleting this plugin configuration will remove all associated settings and data. Are you sure you want to delete this plugin?` + }); const reloadPlugins = useCallback(() => { api @@ -465,7 +588,10 @@ export function PluginListTable({ props }: { props: InvenTreeTableProps }) { color="green" icon={} tooltip={t`Install Plugin`} - onClick={() => installPluginModal.open()} + onClick={() => { + setPluginPackage(''); + installPluginModal.open(); + }} /> ); } @@ -476,9 +602,11 @@ export function PluginListTable({ props }: { props: InvenTreeTableProps }) { return ( <> {installPluginModal.modal} + {uninstallPluginModal.modal} + {deletePluginModal.modal} { if (!id) return false; return ; @@ -489,11 +617,7 @@ export function PluginListTable({ props }: { props: InvenTreeTableProps }) { tableState={table} columns={pluginTableColumns} props={{ - ...props, enableDownload: false, - params: { - ...props.params - }, rowActions: rowActions, onRowClick: (plugin) => navigate(`${plugin.pk}/`), tableActions: tableActions, diff --git a/src/frontend/src/tables/settings/CustomUnitsTable.tsx b/src/frontend/src/tables/settings/CustomUnitsTable.tsx index 4e7bbc30fe..0f2417e6d9 100644 --- a/src/frontend/src/tables/settings/CustomUnitsTable.tsx +++ b/src/frontend/src/tables/settings/CustomUnitsTable.tsx @@ -52,9 +52,7 @@ export default function CustomUnitsTable() { onFormSuccess: table.refreshTable }); - const [selectedUnit, setSelectedUnit] = useState( - undefined - ); + const [selectedUnit, setSelectedUnit] = useState(-1); const editUnit = useEditApiFormModal({ url: ApiEndpoints.custom_unit_list, diff --git a/src/frontend/src/tables/settings/GroupTable.tsx b/src/frontend/src/tables/settings/GroupTable.tsx index 78538b7381..3e86bf9c64 100644 --- a/src/frontend/src/tables/settings/GroupTable.tsx +++ b/src/frontend/src/tables/settings/GroupTable.tsx @@ -118,9 +118,7 @@ export function GroupTable() { ]; }, []); - const [selectedGroup, setSelectedGroup] = useState( - undefined - ); + const [selectedGroup, setSelectedGroup] = useState(-1); const deleteGroup = useDeleteApiFormModal({ url: ApiEndpoints.group_list, diff --git a/src/frontend/src/tables/stock/StockLocationTable.tsx b/src/frontend/src/tables/stock/StockLocationTable.tsx index 3437e6843e..8573207b82 100644 --- a/src/frontend/src/tables/stock/StockLocationTable.tsx +++ b/src/frontend/src/tables/stock/StockLocationTable.tsx @@ -98,9 +98,7 @@ export function StockLocationTable({ parentId }: { parentId?: any }) { } }); - const [selectedLocation, setSelectedLocation] = useState( - undefined - ); + const [selectedLocation, setSelectedLocation] = useState(-1); const editLocation = useEditApiFormModal({ url: ApiEndpoints.stock_location_list,