mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
[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
This commit is contained in:
parent
cd803640a9
commit
c0c4e9c226
@ -1,11 +1,15 @@
|
|||||||
"""InvenTree API version information."""
|
"""InvenTree API version information."""
|
||||||
|
|
||||||
# InvenTree API version
|
# 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."""
|
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||||
|
|
||||||
INVENTREE_API_TEXT = """
|
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
|
v165 -> 2024-01-28 : https://github.com/inventree/InvenTree/pull/6040
|
||||||
- Adds supplier_part.name, part.creation_user, part.required_for_sales_order
|
- Adds supplier_part.name, part.creation_user, part.required_for_sales_order
|
||||||
|
|
||||||
|
@ -1864,6 +1864,12 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
|||||||
'validator': bool,
|
'validator': bool,
|
||||||
'requires_restart': True,
|
'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
|
# Settings for plugin mixin features
|
||||||
'ENABLE_PLUGINS_URL': {
|
'ENABLE_PLUGINS_URL': {
|
||||||
'name': _('Enable URL integration'),
|
'name': _('Enable URL integration'),
|
||||||
|
@ -49,15 +49,15 @@ class PluginSettingInline(admin.TabularInline):
|
|||||||
class PluginConfigAdmin(admin.ModelAdmin):
|
class PluginConfigAdmin(admin.ModelAdmin):
|
||||||
"""Custom admin with restricted id fields."""
|
"""Custom admin with restricted id fields."""
|
||||||
|
|
||||||
readonly_fields = ['key', 'name']
|
readonly_fields = ['key', 'name', 'package_name']
|
||||||
list_display = [
|
list_display = [
|
||||||
'name',
|
'name',
|
||||||
'key',
|
'key',
|
||||||
'__str__',
|
|
||||||
'active',
|
'active',
|
||||||
'is_builtin',
|
'is_builtin',
|
||||||
'is_sample',
|
'is_sample',
|
||||||
'is_installed',
|
'is_installed',
|
||||||
|
'is_package',
|
||||||
]
|
]
|
||||||
list_filter = ['active']
|
list_filter = ['active']
|
||||||
actions = [plugin_activate, plugin_deactivate]
|
actions = [plugin_activate, plugin_deactivate]
|
||||||
|
@ -1,7 +1,10 @@
|
|||||||
"""API for the plugin app."""
|
"""API for the plugin app."""
|
||||||
|
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
from django.urls import include, path, re_path
|
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 django_filters.rest_framework import DjangoFilterBackend
|
||||||
from drf_spectacular.utils import extend_schema
|
from drf_spectacular.utils import extend_schema
|
||||||
from rest_framework import permissions, status
|
from rest_framework import permissions, status
|
||||||
@ -13,7 +16,6 @@ import plugin.serializers as PluginSerializers
|
|||||||
from common.api import GlobalSettingsPermissions
|
from common.api import GlobalSettingsPermissions
|
||||||
from InvenTree.api import MetadataView
|
from InvenTree.api import MetadataView
|
||||||
from InvenTree.filters import SEARCH_ORDER_FILTER
|
from InvenTree.filters import SEARCH_ORDER_FILTER
|
||||||
from InvenTree.helpers import str2bool
|
|
||||||
from InvenTree.mixins import (
|
from InvenTree.mixins import (
|
||||||
CreateAPI,
|
CreateAPI,
|
||||||
ListAPI,
|
ListAPI,
|
||||||
@ -30,6 +32,87 @@ from plugin.models import PluginConfig, PluginSetting
|
|||||||
from plugin.plugin import InvenTreePlugin
|
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):
|
class PluginList(ListAPI):
|
||||||
"""API endpoint for list of PluginConfig objects.
|
"""API endpoint for list of PluginConfig objects.
|
||||||
|
|
||||||
@ -41,70 +124,11 @@ class PluginList(ListAPI):
|
|||||||
# e.g. determining which label printing plugins are available
|
# e.g. determining which label printing plugins are available
|
||||||
permission_classes = [permissions.IsAuthenticated]
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
|
filterset_class = PluginFilter
|
||||||
|
|
||||||
serializer_class = PluginSerializers.PluginConfigSerializer
|
serializer_class = PluginSerializers.PluginConfigSerializer
|
||||||
queryset = PluginConfig.objects.all()
|
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
|
filter_backends = SEARCH_ORDER_FILTER
|
||||||
|
|
||||||
filterset_fields = ['active']
|
filterset_fields = ['active']
|
||||||
@ -132,6 +156,20 @@ class PluginDetail(RetrieveUpdateDestroyAPI):
|
|||||||
queryset = PluginConfig.objects.all()
|
queryset = PluginConfig.objects.all()
|
||||||
serializer_class = PluginSerializers.PluginConfigSerializer
|
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):
|
class PluginInstall(CreateAPI):
|
||||||
"""Endpoint for installing a new plugin."""
|
"""Endpoint for installing a new plugin."""
|
||||||
@ -156,6 +194,18 @@ class PluginInstall(CreateAPI):
|
|||||||
return serializer.save()
|
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):
|
class PluginActivate(UpdateAPI):
|
||||||
"""Endpoint for activating a plugin.
|
"""Endpoint for activating a plugin.
|
||||||
|
|
||||||
@ -398,6 +448,11 @@ plugin_api_urls = [
|
|||||||
PluginActivate.as_view(),
|
PluginActivate.as_view(),
|
||||||
name='api-plugin-detail-activate',
|
name='api-plugin-detail-activate',
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
'uninstall/',
|
||||||
|
PluginUninstall.as_view(),
|
||||||
|
name='api-plugin-uninstall',
|
||||||
|
),
|
||||||
path('', PluginDetail.as_view(), name='api-plugin-detail'),
|
path('', PluginDetail.as_view(), name='api-plugin-detail'),
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
|
@ -9,6 +9,9 @@ from django.conf import settings
|
|||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
import plugin.models
|
||||||
|
from InvenTree.exceptions import log_error
|
||||||
|
|
||||||
logger = logging.getLogger('inventree')
|
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):
|
def check_package_path(packagename: str):
|
||||||
"""Determine the install path of a particular package.
|
"""Determine the install path of a particular package.
|
||||||
|
|
||||||
@ -57,6 +87,8 @@ def check_package_path(packagename: str):
|
|||||||
return match.group(1)
|
return match.group(1)
|
||||||
|
|
||||||
except subprocess.CalledProcessError as error:
|
except subprocess.CalledProcessError as error:
|
||||||
|
log_error('check_package_path')
|
||||||
|
|
||||||
output = error.output.decode('utf-8')
|
output = error.output.decode('utf-8')
|
||||||
logger.exception('Plugin lookup failed: %s', str(output))
|
logger.exception('Plugin lookup failed: %s', str(output))
|
||||||
return False
|
return False
|
||||||
@ -80,16 +112,18 @@ def install_plugins_file():
|
|||||||
except subprocess.CalledProcessError as error:
|
except subprocess.CalledProcessError as error:
|
||||||
output = error.output.decode('utf-8')
|
output = error.output.decode('utf-8')
|
||||||
logger.exception('Plugin file installation failed: %s', str(output))
|
logger.exception('Plugin file installation failed: %s', str(output))
|
||||||
|
log_error('pip')
|
||||||
return False
|
return False
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.exception('Plugin file installation failed: %s', exc)
|
logger.exception('Plugin file installation failed: %s', exc)
|
||||||
|
log_error('pip')
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# At this point, the plugins file has been installed
|
# At this point, the plugins file has been installed
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def add_plugin_to_file(install_name):
|
def update_plugins_file(install_name, remove=False):
|
||||||
"""Add a plugin to the plugins file."""
|
"""Add a plugin to the plugins file."""
|
||||||
logger.info('Adding plugin to plugins file: %s', install_name)
|
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))
|
logger.warning('Plugin file %s does not exist', str(pf))
|
||||||
return
|
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
|
# First, read in existing plugin file
|
||||||
try:
|
try:
|
||||||
with pf.open(mode='r') as f:
|
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))
|
logger.exception('Failed to read plugins file: %s', str(exc))
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Reconstruct output file
|
||||||
|
output = []
|
||||||
|
|
||||||
|
found = False
|
||||||
|
|
||||||
# Check if plugin is already in file
|
# Check if plugin is already in file
|
||||||
for line in lines:
|
for line in lines:
|
||||||
if line.strip() == install_name:
|
# Ignore processing for any commented lines
|
||||||
logger.debug('Plugin already exists in file')
|
if line.strip().startswith('#'):
|
||||||
return
|
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
|
# Append plugin to file
|
||||||
lines.append(f'{install_name}')
|
if not found and not remove:
|
||||||
|
output.append(install_name)
|
||||||
|
|
||||||
# Write file back to disk
|
# Write file back to disk
|
||||||
try:
|
try:
|
||||||
with pf.open(mode='w') as f:
|
with pf.open(mode='w') as f:
|
||||||
for line in lines:
|
for line in output:
|
||||||
f.write(line)
|
f.write(line)
|
||||||
|
|
||||||
if not line.endswith('\n'):
|
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))
|
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.
|
"""Install a plugin into the python virtual environment.
|
||||||
|
|
||||||
Rules:
|
Args:
|
||||||
- A staff user account is required
|
packagename: Optional package name to install
|
||||||
- We must detect that we are running within a virtual environment
|
url: Optional URL to install from
|
||||||
|
user: Optional user performing the installation
|
||||||
|
version: Optional version specifier
|
||||||
"""
|
"""
|
||||||
if user and not user.is_staff:
|
if user and not user.is_staff:
|
||||||
raise ValidationError(
|
raise ValidationError(_('Only staff users can administer plugins'))
|
||||||
_('Permission denied: only staff users can install 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
|
# Check if we are running in a virtual environment
|
||||||
# For now, just log a warning
|
# For now, just log a warning
|
||||||
@ -178,6 +231,9 @@ def install_plugin(url=None, packagename=None, user=None):
|
|||||||
# use pypi
|
# use pypi
|
||||||
full_pkg = packagename
|
full_pkg = packagename
|
||||||
|
|
||||||
|
if version:
|
||||||
|
full_pkg = f'{full_pkg}=={version}'
|
||||||
|
|
||||||
install_name.append(full_pkg)
|
install_name.append(full_pkg)
|
||||||
|
|
||||||
ret = {}
|
ret = {}
|
||||||
@ -195,26 +251,10 @@ def install_plugin(url=None, packagename=None, user=None):
|
|||||||
ret['result'] = _(f'Installed plugin into {path}')
|
ret['result'] = _(f'Installed plugin into {path}')
|
||||||
|
|
||||||
except subprocess.CalledProcessError as error:
|
except subprocess.CalledProcessError as error:
|
||||||
# If an error was thrown, we need to parse the output
|
handle_pip_error(error, 'plugin_install')
|
||||||
|
|
||||||
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
|
# 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
|
# Reload the plugin registry, to discover the new plugin
|
||||||
from plugin.registry import registry
|
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)
|
registry.reload_plugins(full_reload=True, force_reload=True, collect=True)
|
||||||
|
|
||||||
return ret
|
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
|
||||||
|
@ -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'),
|
||||||
|
),
|
||||||
|
]
|
@ -42,6 +42,16 @@ class PluginConfig(InvenTree.models.MetadataMixin, models.Model):
|
|||||||
help_text=_('PluginName of the plugin'),
|
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(
|
active = models.BooleanField(
|
||||||
default=False, verbose_name=_('Active'), help_text=_('Is the plugin active')
|
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()
|
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):
|
class PluginSetting(common.models.BaseInvenTreeSetting):
|
||||||
"""This model represents settings for individual plugins."""
|
"""This model represents settings for individual plugins."""
|
||||||
|
@ -337,6 +337,30 @@ class InvenTreePlugin(VersionMixin, MixinBase, MetaBase):
|
|||||||
"""Path to the plugin."""
|
"""Path to the plugin."""
|
||||||
return self.check_package_path()
|
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 <x>',
|
||||||
|
then this function should return '<x>'
|
||||||
|
|
||||||
|
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 <x>',
|
||||||
|
then this function should return '<x>'
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Install name of the package, else None
|
||||||
|
"""
|
||||||
|
return self.check_package_install_name()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def settings_url(self):
|
def settings_url(self):
|
||||||
"""URL to the settings panel for this plugin."""
|
"""URL to the settings panel for this plugin."""
|
||||||
|
@ -439,8 +439,8 @@ class PluginsRegistry:
|
|||||||
raw_module = importlib.import_module(plugin)
|
raw_module = importlib.import_module(plugin)
|
||||||
modules = get_plugins(raw_module, InvenTreePlugin, path=parent_path)
|
modules = get_plugins(raw_module, InvenTreePlugin, path=parent_path)
|
||||||
|
|
||||||
if modules:
|
for item in modules or []:
|
||||||
[collected_plugins.append(item) for item in modules]
|
collected_plugins.append(item)
|
||||||
|
|
||||||
# From this point any plugins are considered "external" and only loaded if plugins are explicitly enabled
|
# From this point any plugins are considered "external" and only loaded if plugins are explicitly enabled
|
||||||
if settings.PLUGINS_ENABLED:
|
if settings.PLUGINS_ENABLED:
|
||||||
@ -453,6 +453,7 @@ class PluginsRegistry:
|
|||||||
try:
|
try:
|
||||||
plugin = entry.load()
|
plugin = entry.load()
|
||||||
plugin.is_package = True
|
plugin.is_package = True
|
||||||
|
plugin.package_name = getattr(entry.dist, 'name', None)
|
||||||
plugin._get_package_metadata()
|
plugin._get_package_metadata()
|
||||||
collected_plugins.append(plugin)
|
collected_plugins.append(plugin)
|
||||||
except Exception as error: # pragma: no cover
|
except Exception as error: # pragma: no cover
|
||||||
@ -549,11 +550,22 @@ class PluginsRegistry:
|
|||||||
# Check if this is a 'builtin' plugin
|
# Check if this is a 'builtin' plugin
|
||||||
builtin = plg.check_is_builtin()
|
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
|
# Auto-enable builtin plugins
|
||||||
if builtin and plg_db and not plg_db.active:
|
if builtin and plg_db and not plg_db.active:
|
||||||
plg_db.active = True
|
plg_db.active = True
|
||||||
plg_db.save()
|
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:
|
# Determine if this plugin should be loaded:
|
||||||
# - If PLUGIN_TESTING is enabled
|
# - If PLUGIN_TESTING is enabled
|
||||||
# - If this is a 'builtin' plugin
|
# - If this is a 'builtin' plugin
|
||||||
@ -580,6 +592,7 @@ class PluginsRegistry:
|
|||||||
|
|
||||||
# Safe extra attributes
|
# Safe extra attributes
|
||||||
plg_i.is_package = getattr(plg_i, 'is_package', False)
|
plg_i.is_package = getattr(plg_i, 'is_package', False)
|
||||||
|
|
||||||
plg_i.pk = plg_db.pk if plg_db else None
|
plg_i.pk = plg_db.pk if plg_db else None
|
||||||
plg_i.db = plg_db
|
plg_i.db = plg_db
|
||||||
|
|
||||||
|
@ -51,12 +51,14 @@ class PluginConfigSerializer(serializers.ModelSerializer):
|
|||||||
'pk',
|
'pk',
|
||||||
'key',
|
'key',
|
||||||
'name',
|
'name',
|
||||||
|
'package_name',
|
||||||
'active',
|
'active',
|
||||||
'meta',
|
'meta',
|
||||||
'mixins',
|
'mixins',
|
||||||
'is_builtin',
|
'is_builtin',
|
||||||
'is_sample',
|
'is_sample',
|
||||||
'is_installed',
|
'is_installed',
|
||||||
|
'is_package',
|
||||||
]
|
]
|
||||||
|
|
||||||
read_only_fields = ['key', 'is_builtin', 'is_sample', 'is_installed']
|
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'
|
'Source for the package - this can be a custom registry or a VCS path'
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
packagename = serializers.CharField(
|
packagename = serializers.CharField(
|
||||||
required=False,
|
required=False,
|
||||||
allow_blank=True,
|
allow_blank=True,
|
||||||
@ -89,6 +92,16 @@ class PluginConfigInstallSerializer(serializers.Serializer):
|
|||||||
'Name for the Plugin Package - can also contain a version indicator'
|
'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(
|
confirm = serializers.BooleanField(
|
||||||
label=_('Confirm plugin installation'),
|
label=_('Confirm plugin installation'),
|
||||||
help_text=_(
|
help_text=_(
|
||||||
@ -120,8 +133,12 @@ class PluginConfigInstallSerializer(serializers.Serializer):
|
|||||||
|
|
||||||
packagename = data.get('packagename', '')
|
packagename = data.get('packagename', '')
|
||||||
url = data.get('url', '')
|
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):
|
class PluginConfigEmptySerializer(serializers.Serializer):
|
||||||
@ -193,6 +210,27 @@ class PluginActivateSerializer(serializers.Serializer):
|
|||||||
return instance
|
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):
|
class PluginSettingSerializer(GenericReferencedSettingSerializer):
|
||||||
"""Serializer for the PluginSetting model."""
|
"""Serializer for the PluginSetting model."""
|
||||||
|
|
||||||
|
@ -21,6 +21,7 @@ export function setApiDefaults() {
|
|||||||
const token = useSessionState.getState().token;
|
const token = useSessionState.getState().token;
|
||||||
|
|
||||||
api.defaults.baseURL = host;
|
api.defaults.baseURL = host;
|
||||||
|
api.defaults.timeout = 1000;
|
||||||
|
|
||||||
if (!!token) {
|
if (!!token) {
|
||||||
api.defaults.headers.common['Authorization'] = `Token ${token}`;
|
api.defaults.headers.common['Authorization'] = `Token ${token}`;
|
||||||
|
@ -79,6 +79,7 @@ export interface ApiFormProps {
|
|||||||
onFormSuccess?: (data: any) => void;
|
onFormSuccess?: (data: any) => void;
|
||||||
onFormError?: () => void;
|
onFormError?: () => void;
|
||||||
actions?: ApiFormAction[];
|
actions?: ApiFormAction[];
|
||||||
|
timeout?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function OptionsApiForm({
|
export function OptionsApiForm({
|
||||||
@ -296,6 +297,7 @@ export function ApiForm({ id, props }: { id: string; props: ApiFormProps }) {
|
|||||||
method: method,
|
method: method,
|
||||||
url: url,
|
url: url,
|
||||||
data: data,
|
data: data,
|
||||||
|
timeout: props.timeout,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': hasFiles ? 'multipart/form-data' : 'application/json'
|
'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) {
|
switch (error.response.status) {
|
||||||
case 400:
|
case 400:
|
||||||
// Data validation errors
|
// Data validation errors
|
||||||
const nonFieldErrors: string[] = [];
|
const _nonFieldErrors: string[] = [];
|
||||||
const processErrors = (errors: any, _path?: string) => {
|
const processErrors = (errors: any, _path?: string) => {
|
||||||
for (const [k, v] of Object.entries(errors)) {
|
for (const [k, v] of Object.entries(errors)) {
|
||||||
const path = _path ? `${_path}.${k}` : k;
|
const path = _path ? `${_path}.${k}` : k;
|
||||||
|
|
||||||
if (k === 'non_field_errors') {
|
if (k === 'non_field_errors' || k === '__all__') {
|
||||||
nonFieldErrors.push((v as string[]).join(', '));
|
if (Array.isArray(v)) {
|
||||||
|
_nonFieldErrors.push(...v);
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -358,7 +362,7 @@ export function ApiForm({ id, props }: { id: string; props: ApiFormProps }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
processErrors(error.response.data);
|
processErrors(error.response.data);
|
||||||
setNonFieldErrors(nonFieldErrors);
|
setNonFieldErrors(_nonFieldErrors);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
// Unexpected state on form error
|
// Unexpected state on form error
|
||||||
|
@ -97,6 +97,8 @@ export enum ApiEndpoints {
|
|||||||
plugin_registry_status = 'plugins/status/',
|
plugin_registry_status = 'plugins/status/',
|
||||||
plugin_install = 'plugins/install/',
|
plugin_install = 'plugins/install/',
|
||||||
plugin_reload = 'plugins/reload/',
|
plugin_reload = 'plugins/reload/',
|
||||||
|
plugin_activate = 'plugins/:id/activate/',
|
||||||
|
plugin_uninstall = 'plugins/:id/uninstall/',
|
||||||
|
|
||||||
// Miscellaneous API endpoints
|
// Miscellaneous API endpoints
|
||||||
error_report_list = 'error-report/',
|
error_report_list = 'error-report/',
|
||||||
|
@ -1,11 +1,20 @@
|
|||||||
import { Trans } from '@lingui/macro';
|
import { Trans, t } from '@lingui/macro';
|
||||||
import { Alert, Stack, Title } from '@mantine/core';
|
import { Accordion, Alert, Stack } from '@mantine/core';
|
||||||
import { IconInfoCircle } from '@tabler/icons-react';
|
import { IconInfoCircle } from '@tabler/icons-react';
|
||||||
|
import { lazy } from 'react';
|
||||||
|
|
||||||
|
import { StylishText } from '../../../../components/items/StylishText';
|
||||||
import { GlobalSettingList } from '../../../../components/settings/SettingList';
|
import { GlobalSettingList } from '../../../../components/settings/SettingList';
|
||||||
|
import { Loadable } from '../../../../functions/loading';
|
||||||
import { useServerApiState } from '../../../../states/ApiState';
|
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() {
|
export default function PluginManagementPanel() {
|
||||||
const pluginsEnabled = useServerApiState(
|
const pluginsEnabled = useServerApiState(
|
||||||
@ -26,30 +35,44 @@ export default function PluginManagementPanel() {
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<PluginListTable props={{}} />
|
<Accordion defaultValue="pluginlist">
|
||||||
|
<Accordion.Item value="pluginlist">
|
||||||
|
<Accordion.Control>
|
||||||
|
<StylishText size="lg">{t`Plugins`}</StylishText>
|
||||||
|
</Accordion.Control>
|
||||||
|
<Accordion.Panel>
|
||||||
|
<PluginListTable />
|
||||||
|
</Accordion.Panel>
|
||||||
|
</Accordion.Item>
|
||||||
|
|
||||||
<Stack spacing={'xs'}>
|
<Accordion.Item value="pluginerror">
|
||||||
<Title order={5}>
|
<Accordion.Control>
|
||||||
<Trans>Plugin Error Stack</Trans>
|
<StylishText size="lg">{t`Plugin Errors`}</StylishText>
|
||||||
</Title>
|
</Accordion.Control>
|
||||||
<PluginErrorTable props={{}} />
|
<Accordion.Panel>
|
||||||
</Stack>
|
<PluginErrorTable />
|
||||||
|
</Accordion.Panel>
|
||||||
|
</Accordion.Item>
|
||||||
|
|
||||||
<Stack spacing={'xs'}>
|
<Accordion.Item value="pluginsettings">
|
||||||
<Title order={5}>
|
<Accordion.Control>
|
||||||
<Trans>Plugin Settings</Trans>
|
<StylishText size="lg">{t`Plugin Settings`}</StylishText>
|
||||||
</Title>
|
</Accordion.Control>
|
||||||
<GlobalSettingList
|
<Accordion.Panel>
|
||||||
keys={[
|
<GlobalSettingList
|
||||||
'ENABLE_PLUGINS_SCHEDULE',
|
keys={[
|
||||||
'ENABLE_PLUGINS_EVENTS',
|
'ENABLE_PLUGINS_SCHEDULE',
|
||||||
'ENABLE_PLUGINS_URL',
|
'ENABLE_PLUGINS_EVENTS',
|
||||||
'ENABLE_PLUGINS_NAVIGATION',
|
'ENABLE_PLUGINS_URL',
|
||||||
'ENABLE_PLUGINS_APP',
|
'ENABLE_PLUGINS_NAVIGATION',
|
||||||
'PLUGIN_ON_STARTUP'
|
'ENABLE_PLUGINS_APP',
|
||||||
]}
|
'PLUGIN_ON_STARTUP',
|
||||||
/>
|
'PLUGIN_UPDATE_CHECK'
|
||||||
</Stack>
|
]}
|
||||||
|
/>
|
||||||
|
</Accordion.Panel>
|
||||||
|
</Accordion.Item>
|
||||||
|
</Accordion>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -127,9 +127,7 @@ export function AddressTable({
|
|||||||
onFormSuccess: table.refreshTable
|
onFormSuccess: table.refreshTable
|
||||||
});
|
});
|
||||||
|
|
||||||
const [selectedAddress, setSelectedAddress] = useState<number | undefined>(
|
const [selectedAddress, setSelectedAddress] = useState<number>(-1);
|
||||||
undefined
|
|
||||||
);
|
|
||||||
|
|
||||||
const editAddress = useEditApiFormModal({
|
const editAddress = useEditApiFormModal({
|
||||||
url: ApiEndpoints.address_list,
|
url: ApiEndpoints.address_list,
|
||||||
|
@ -88,9 +88,7 @@ export function PartCategoryTable({ parentId }: { parentId?: any }) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const [selectedCategory, setSelectedCategory] = useState<number | undefined>(
|
const [selectedCategory, setSelectedCategory] = useState<number>(-1);
|
||||||
undefined
|
|
||||||
);
|
|
||||||
|
|
||||||
const editCategory = useEditApiFormModal({
|
const editCategory = useEditApiFormModal({
|
||||||
url: ApiEndpoints.category_list,
|
url: ApiEndpoints.category_list,
|
||||||
|
@ -85,9 +85,7 @@ export default function PartTestTemplateTable({ partId }: { partId: number }) {
|
|||||||
onFormSuccess: table.refreshTable
|
onFormSuccess: table.refreshTable
|
||||||
});
|
});
|
||||||
|
|
||||||
const [selectedTest, setSelectedTest] = useState<number | undefined>(
|
const [selectedTest, setSelectedTest] = useState<number>(-1);
|
||||||
undefined
|
|
||||||
);
|
|
||||||
|
|
||||||
const editTestTemplate = useEditApiFormModal({
|
const editTestTemplate = useEditApiFormModal({
|
||||||
url: ApiEndpoints.part_test_template_list,
|
url: ApiEndpoints.part_test_template_list,
|
||||||
|
@ -6,7 +6,7 @@ import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
|||||||
import { useTable } from '../../hooks/UseTable';
|
import { useTable } from '../../hooks/UseTable';
|
||||||
import { apiUrl } from '../../states/ApiState';
|
import { apiUrl } from '../../states/ApiState';
|
||||||
import { TableColumn } from '../Column';
|
import { TableColumn } from '../Column';
|
||||||
import { InvenTreeTable, InvenTreeTableProps } from '../InvenTreeTable';
|
import { InvenTreeTable } from '../InvenTreeTable';
|
||||||
|
|
||||||
export interface PluginRegistryErrorI {
|
export interface PluginRegistryErrorI {
|
||||||
id: number;
|
id: number;
|
||||||
@ -18,7 +18,7 @@ export interface PluginRegistryErrorI {
|
|||||||
/**
|
/**
|
||||||
* Table displaying list of plugin registry errors
|
* Table displaying list of plugin registry errors
|
||||||
*/
|
*/
|
||||||
export function PluginErrorTable({ props }: { props: InvenTreeTableProps }) {
|
export default function PluginErrorTable() {
|
||||||
const table = useTable('registryErrors');
|
const table = useTable('registryErrors');
|
||||||
|
|
||||||
const registryErrorTableColumns: TableColumn<PluginRegistryErrorI>[] =
|
const registryErrorTableColumns: TableColumn<PluginRegistryErrorI>[] =
|
||||||
@ -47,16 +47,12 @@ export function PluginErrorTable({ props }: { props: InvenTreeTableProps }) {
|
|||||||
tableState={table}
|
tableState={table}
|
||||||
columns={registryErrorTableColumns}
|
columns={registryErrorTableColumns}
|
||||||
props={{
|
props={{
|
||||||
...props,
|
|
||||||
dataFormatter: (data: any) =>
|
dataFormatter: (data: any) =>
|
||||||
data.registry_errors.map((e: any, i: number) => ({ id: i, ...e })),
|
data.registry_errors.map((e: any, i: number) => ({ id: i, ...e })),
|
||||||
idAccessor: 'id',
|
idAccessor: 'id',
|
||||||
enableDownload: false,
|
enableDownload: false,
|
||||||
enableFilters: false,
|
enableFilters: false,
|
||||||
enableSearch: false,
|
enableSearch: false
|
||||||
params: {
|
|
||||||
...props.params
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
@ -16,11 +16,12 @@ import {
|
|||||||
IconCircleCheck,
|
IconCircleCheck,
|
||||||
IconCircleX,
|
IconCircleX,
|
||||||
IconHelpCircle,
|
IconHelpCircle,
|
||||||
|
IconInfoCircle,
|
||||||
IconPlaylistAdd,
|
IconPlaylistAdd,
|
||||||
IconRefresh
|
IconRefresh
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { IconDots } 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 { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
import { api } from '../../App';
|
import { api } from '../../App';
|
||||||
@ -35,13 +36,17 @@ import { DetailDrawer } from '../../components/nav/DetailDrawer';
|
|||||||
import { PluginSettingList } from '../../components/settings/SettingList';
|
import { PluginSettingList } from '../../components/settings/SettingList';
|
||||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||||
import { openEditApiForm } from '../../functions/forms';
|
import { openEditApiForm } from '../../functions/forms';
|
||||||
import { useCreateApiFormModal } from '../../hooks/UseForm';
|
import {
|
||||||
|
useCreateApiFormModal,
|
||||||
|
useDeleteApiFormModal,
|
||||||
|
useEditApiFormModal
|
||||||
|
} from '../../hooks/UseForm';
|
||||||
import { useInstance } from '../../hooks/UseInstance';
|
import { useInstance } from '../../hooks/UseInstance';
|
||||||
import { useTable } from '../../hooks/UseTable';
|
import { useTable } from '../../hooks/UseTable';
|
||||||
import { apiUrl, useServerApiState } from '../../states/ApiState';
|
import { apiUrl, useServerApiState } from '../../states/ApiState';
|
||||||
import { useUserState } from '../../states/UserState';
|
import { useUserState } from '../../states/UserState';
|
||||||
import { TableColumn } from '../Column';
|
import { TableColumn } from '../Column';
|
||||||
import { InvenTreeTable, InvenTreeTableProps } from '../InvenTreeTable';
|
import { InvenTreeTable } from '../InvenTreeTable';
|
||||||
import { RowAction } from '../RowActions';
|
import { RowAction } from '../RowActions';
|
||||||
|
|
||||||
export interface PluginI {
|
export interface PluginI {
|
||||||
@ -52,6 +57,8 @@ export interface PluginI {
|
|||||||
is_builtin: boolean;
|
is_builtin: boolean;
|
||||||
is_sample: boolean;
|
is_sample: boolean;
|
||||||
is_installed: boolean;
|
is_installed: boolean;
|
||||||
|
is_package: boolean;
|
||||||
|
package_name: string | null;
|
||||||
meta: {
|
meta: {
|
||||||
author: string | null;
|
author: string | null;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
@ -189,9 +196,16 @@ export function PluginDrawer({
|
|||||||
<Trans>Package information</Trans>
|
<Trans>Package information</Trans>
|
||||||
</Title>
|
</Title>
|
||||||
<Stack pos="relative" spacing="xs">
|
<Stack pos="relative" spacing="xs">
|
||||||
|
{plugin?.is_package && (
|
||||||
|
<InfoItem
|
||||||
|
type="text"
|
||||||
|
name={t`Package Name`}
|
||||||
|
value={plugin?.package_name}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<InfoItem
|
<InfoItem
|
||||||
type="text"
|
type="text"
|
||||||
name={t`Installation path`}
|
name={t`Installation Path`}
|
||||||
value={plugin?.meta.package_path}
|
value={plugin?.meta.package_path}
|
||||||
/>
|
/>
|
||||||
<InfoItem
|
<InfoItem
|
||||||
@ -199,6 +213,11 @@ export function PluginDrawer({
|
|||||||
name={t`Builtin`}
|
name={t`Builtin`}
|
||||||
value={plugin?.is_builtin}
|
value={plugin?.is_builtin}
|
||||||
/>
|
/>
|
||||||
|
<InfoItem
|
||||||
|
type="boolean"
|
||||||
|
name={t`Package`}
|
||||||
|
value={plugin?.is_package}
|
||||||
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Card>
|
</Card>
|
||||||
@ -247,9 +266,10 @@ function PluginIcon(plugin: PluginI) {
|
|||||||
/**
|
/**
|
||||||
* Table displaying list of available plugins
|
* Table displaying list of available plugins
|
||||||
*/
|
*/
|
||||||
export function PluginListTable({ props }: { props: InvenTreeTableProps }) {
|
export default function PluginListTable() {
|
||||||
const table = useTable('plugin');
|
const table = useTable('plugin');
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const user = useUserState();
|
||||||
|
|
||||||
const pluginsEnabled = useServerApiState(
|
const pluginsEnabled = useServerApiState(
|
||||||
(state) => state.server.plugins_enabled
|
(state) => state.server.plugins_enabled
|
||||||
@ -337,7 +357,7 @@ export function PluginListTable({ props }: { props: InvenTreeTableProps }) {
|
|||||||
confirm: t`Confirm`
|
confirm: t`Confirm`
|
||||||
},
|
},
|
||||||
onConfirm: () => {
|
onConfirm: () => {
|
||||||
let url = apiUrl(ApiEndpoints.plugin_list, plugin_id) + 'activate/';
|
let url = apiUrl(ApiEndpoints.plugin_activate, plugin_id);
|
||||||
|
|
||||||
const id = 'plugin-activate';
|
const id = 'plugin-activate';
|
||||||
|
|
||||||
@ -349,7 +369,13 @@ export function PluginListTable({ props }: { props: InvenTreeTableProps }) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
api
|
api
|
||||||
.patch(url, { active: active })
|
.patch(
|
||||||
|
url,
|
||||||
|
{ active: active },
|
||||||
|
{
|
||||||
|
timeout: 30 * 1000
|
||||||
|
}
|
||||||
|
)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
table.refreshTable();
|
table.refreshTable();
|
||||||
notifications.hide(id);
|
notifications.hide(id);
|
||||||
@ -376,42 +402,98 @@ export function PluginListTable({ props }: { props: InvenTreeTableProps }) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Determine available actions for a given plugin
|
// Determine available actions for a given plugin
|
||||||
function rowActions(record: any): RowAction[] {
|
const rowActions = useCallback(
|
||||||
let actions: RowAction[] = [];
|
(record: any) => {
|
||||||
|
// TODO: Plugin actions should be updated based on on the users's permissions
|
||||||
|
|
||||||
if (!record.is_builtin && record.is_installed) {
|
let actions: RowAction[] = [];
|
||||||
if (record.active) {
|
|
||||||
|
if (!record.is_builtin && record.is_installed) {
|
||||||
|
if (record.active) {
|
||||||
|
actions.push({
|
||||||
|
title: t`Deactivate`,
|
||||||
|
color: 'red',
|
||||||
|
icon: <IconCircleX />,
|
||||||
|
onClick: () => {
|
||||||
|
activatePlugin(record.pk, record.name, false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
actions.push({
|
||||||
|
title: t`Activate`,
|
||||||
|
color: 'green',
|
||||||
|
icon: <IconCircleCheck />,
|
||||||
|
onClick: () => {
|
||||||
|
activatePlugin(record.pk, record.name, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Active 'package' plugins can be updated
|
||||||
|
if (record.active && record.is_package && record.package_name) {
|
||||||
actions.push({
|
actions.push({
|
||||||
title: t`Deactivate`,
|
title: t`Update`,
|
||||||
color: 'red',
|
color: 'blue',
|
||||||
icon: <IconCircleX />,
|
icon: <IconRefresh />,
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
activatePlugin(record.pk, record.name, false);
|
setPluginPackage(record.package_name);
|
||||||
}
|
installPluginModal.open();
|
||||||
});
|
|
||||||
} else {
|
|
||||||
actions.push({
|
|
||||||
title: t`Activate`,
|
|
||||||
color: 'green',
|
|
||||||
icon: <IconCircleCheck />,
|
|
||||||
onClick: () => {
|
|
||||||
activatePlugin(record.pk, record.name, true);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
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: <IconCircleX />,
|
||||||
|
onClick: () => {
|
||||||
|
setSelectedPlugin(record.pk);
|
||||||
|
uninstallPluginModal.open();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Uninstalled 'package' plugins can be deleted
|
||||||
|
if (!record.is_installed) {
|
||||||
|
actions.push({
|
||||||
|
title: t`Delete`,
|
||||||
|
color: 'red',
|
||||||
|
icon: <IconCircleX />,
|
||||||
|
onClick: () => {
|
||||||
|
setSelectedPlugin(record.pk);
|
||||||
|
deletePluginModal.open();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return actions;
|
||||||
|
},
|
||||||
|
[user, pluginsEnabled]
|
||||||
|
);
|
||||||
|
|
||||||
|
const [pluginPackage, setPluginPackage] = useState<string>('');
|
||||||
|
|
||||||
const installPluginModal = useCreateApiFormModal({
|
const installPluginModal = useCreateApiFormModal({
|
||||||
title: t`Install plugin`,
|
title: t`Install plugin`,
|
||||||
url: ApiEndpoints.plugin_install,
|
url: ApiEndpoints.plugin_install,
|
||||||
|
timeout: 30000,
|
||||||
fields: {
|
fields: {
|
||||||
packagename: {},
|
packagename: {},
|
||||||
url: {},
|
url: {},
|
||||||
|
version: {},
|
||||||
confirm: {}
|
confirm: {}
|
||||||
},
|
},
|
||||||
|
initialData: {
|
||||||
|
packagename: pluginPackage
|
||||||
|
},
|
||||||
closeOnClickOutside: false,
|
closeOnClickOutside: false,
|
||||||
submitText: t`Install`,
|
submitText: t`Install`,
|
||||||
successMessage: undefined,
|
successMessage: undefined,
|
||||||
@ -427,7 +509,48 @@ export function PluginListTable({ props }: { props: InvenTreeTableProps }) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const user = useUserState();
|
const [selectedPlugin, setSelectedPlugin] = useState<number>(-1);
|
||||||
|
|
||||||
|
const uninstallPluginModal = useEditApiFormModal({
|
||||||
|
title: t`Uninstall Plugin`,
|
||||||
|
url: ApiEndpoints.plugin_uninstall,
|
||||||
|
pk: selectedPlugin,
|
||||||
|
fetchInitialData: false,
|
||||||
|
timeout: 30000,
|
||||||
|
fields: {
|
||||||
|
delete_config: {}
|
||||||
|
},
|
||||||
|
preFormContent: (
|
||||||
|
<Alert
|
||||||
|
color="red"
|
||||||
|
icon={<IconInfoCircle />}
|
||||||
|
title={t`Confirm plugin uninstall`}
|
||||||
|
>
|
||||||
|
<Stack spacing="xs">
|
||||||
|
<Text>{t`The selected plugin will be uninstalled.`}</Text>
|
||||||
|
<Text>{t`This action cannot be undone.`}</Text>
|
||||||
|
</Stack>
|
||||||
|
</Alert>
|
||||||
|
),
|
||||||
|
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(() => {
|
const reloadPlugins = useCallback(() => {
|
||||||
api
|
api
|
||||||
@ -465,7 +588,10 @@ export function PluginListTable({ props }: { props: InvenTreeTableProps }) {
|
|||||||
color="green"
|
color="green"
|
||||||
icon={<IconPlaylistAdd />}
|
icon={<IconPlaylistAdd />}
|
||||||
tooltip={t`Install Plugin`}
|
tooltip={t`Install Plugin`}
|
||||||
onClick={() => installPluginModal.open()}
|
onClick={() => {
|
||||||
|
setPluginPackage('');
|
||||||
|
installPluginModal.open();
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -476,9 +602,11 @@ export function PluginListTable({ props }: { props: InvenTreeTableProps }) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{installPluginModal.modal}
|
{installPluginModal.modal}
|
||||||
|
{uninstallPluginModal.modal}
|
||||||
|
{deletePluginModal.modal}
|
||||||
<DetailDrawer
|
<DetailDrawer
|
||||||
title={t`Plugin detail`}
|
title={t`Plugin detail`}
|
||||||
size={'lg'}
|
size={'xl'}
|
||||||
renderContent={(id) => {
|
renderContent={(id) => {
|
||||||
if (!id) return false;
|
if (!id) return false;
|
||||||
return <PluginDrawer id={id} refreshTable={table.refreshTable} />;
|
return <PluginDrawer id={id} refreshTable={table.refreshTable} />;
|
||||||
@ -489,11 +617,7 @@ export function PluginListTable({ props }: { props: InvenTreeTableProps }) {
|
|||||||
tableState={table}
|
tableState={table}
|
||||||
columns={pluginTableColumns}
|
columns={pluginTableColumns}
|
||||||
props={{
|
props={{
|
||||||
...props,
|
|
||||||
enableDownload: false,
|
enableDownload: false,
|
||||||
params: {
|
|
||||||
...props.params
|
|
||||||
},
|
|
||||||
rowActions: rowActions,
|
rowActions: rowActions,
|
||||||
onRowClick: (plugin) => navigate(`${plugin.pk}/`),
|
onRowClick: (plugin) => navigate(`${plugin.pk}/`),
|
||||||
tableActions: tableActions,
|
tableActions: tableActions,
|
||||||
|
@ -52,9 +52,7 @@ export default function CustomUnitsTable() {
|
|||||||
onFormSuccess: table.refreshTable
|
onFormSuccess: table.refreshTable
|
||||||
});
|
});
|
||||||
|
|
||||||
const [selectedUnit, setSelectedUnit] = useState<number | undefined>(
|
const [selectedUnit, setSelectedUnit] = useState<number>(-1);
|
||||||
undefined
|
|
||||||
);
|
|
||||||
|
|
||||||
const editUnit = useEditApiFormModal({
|
const editUnit = useEditApiFormModal({
|
||||||
url: ApiEndpoints.custom_unit_list,
|
url: ApiEndpoints.custom_unit_list,
|
||||||
|
@ -118,9 +118,7 @@ export function GroupTable() {
|
|||||||
];
|
];
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const [selectedGroup, setSelectedGroup] = useState<number | undefined>(
|
const [selectedGroup, setSelectedGroup] = useState<number>(-1);
|
||||||
undefined
|
|
||||||
);
|
|
||||||
|
|
||||||
const deleteGroup = useDeleteApiFormModal({
|
const deleteGroup = useDeleteApiFormModal({
|
||||||
url: ApiEndpoints.group_list,
|
url: ApiEndpoints.group_list,
|
||||||
|
@ -98,9 +98,7 @@ export function StockLocationTable({ parentId }: { parentId?: any }) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const [selectedLocation, setSelectedLocation] = useState<number | undefined>(
|
const [selectedLocation, setSelectedLocation] = useState<number>(-1);
|
||||||
undefined
|
|
||||||
);
|
|
||||||
|
|
||||||
const editLocation = useEditApiFormModal({
|
const editLocation = useEditApiFormModal({
|
||||||
url: ApiEndpoints.stock_location_list,
|
url: ApiEndpoints.stock_location_list,
|
||||||
|
Loading…
Reference in New Issue
Block a user