[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:
Oliver 2024-02-07 02:08:30 +11:00 committed by GitHub
parent cd803640a9
commit c0c4e9c226
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 607 additions and 189 deletions

View File

@ -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

View File

@ -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'),

View File

@ -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]

View File

@ -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'),
]), ]),
), ),

View File

@ -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

View File

@ -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'),
),
]

View File

@ -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."""

View File

@ -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."""

View File

@ -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

View File

@ -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."""

View File

@ -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}`;

View File

@ -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

View File

@ -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/',

View File

@ -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>
); );
} }

View File

@ -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,

View File

@ -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,

View File

@ -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,

View File

@ -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
}
}} }}
/> />
); );

View File

@ -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,

View File

@ -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,

View File

@ -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,

View File

@ -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,