diff --git a/src/backend/InvenTree/InvenTree/api.py b/src/backend/InvenTree/InvenTree/api.py index 63dff2a881..b220e09135 100644 --- a/src/backend/InvenTree/InvenTree/api.py +++ b/src/backend/InvenTree/InvenTree/api.py @@ -550,6 +550,10 @@ class MetadataView(RetrieveUpdateAPI): """Return the model type associated with this API instance.""" model = self.kwargs.get(self.MODEL_REF, None) + if 'lookup_field' in self.kwargs: + # Set custom lookup field (instead of default 'pk' value) if supplied + self.lookup_field = self.kwargs.pop('lookup_field') + if model is None: raise ValidationError( f"MetadataView called without '{self.MODEL_REF}' parameter" diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index ecfd6f0314..9ccac7c365 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,11 +1,14 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 196 +INVENTREE_API_VERSION = 197 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v197 - 2024-05-14 : https://github.com/inventree/InvenTree/pull/7224 + - Refactor the plugin API endpoints to use the plugin "key" for lookup, rather than the PK value + v196 - 2024-05-05 : https://github.com/inventree/InvenTree/pull/7160 - Adds "location" field to BuildOutputComplete API endpoint diff --git a/src/backend/InvenTree/common/tests.py b/src/backend/InvenTree/common/tests.py index cc5ac61270..a98ca5cb7b 100644 --- a/src/backend/InvenTree/common/tests.py +++ b/src/backend/InvenTree/common/tests.py @@ -620,7 +620,7 @@ class PluginSettingsApiTest(PluginMixin, InvenTreeAPITestCase): # get data url = reverse( - 'api-plugin-setting-detail', kwargs={'plugin': 'sample', 'key': 'API_KEY'} + 'api-plugin-setting-detail', kwargs={'key': 'sample', 'setting': 'API_KEY'} ) response = self.get(url, expected_code=200) @@ -637,7 +637,7 @@ class PluginSettingsApiTest(PluginMixin, InvenTreeAPITestCase): # Non-existent plugin url = reverse( 'api-plugin-setting-detail', - kwargs={'plugin': 'doesnotexist', 'key': 'doesnotmatter'}, + kwargs={'key': 'doesnotexist', 'setting': 'doesnotmatter'}, ) response = self.get(url, expected_code=404) self.assertIn("Plugin 'doesnotexist' not installed", str(response.data)) @@ -645,7 +645,7 @@ class PluginSettingsApiTest(PluginMixin, InvenTreeAPITestCase): # Wrong key url = reverse( 'api-plugin-setting-detail', - kwargs={'plugin': 'sample', 'key': 'doesnotexist'}, + kwargs={'key': 'sample', 'setting': 'doesnotexist'}, ) response = self.get(url, expected_code=404) self.assertIn( diff --git a/src/backend/InvenTree/plugin/api.py b/src/backend/InvenTree/plugin/api.py index f14ab7e719..0df8638809 100644 --- a/src/backend/InvenTree/plugin/api.py +++ b/src/backend/InvenTree/plugin/api.py @@ -155,6 +155,7 @@ class PluginDetail(RetrieveUpdateDestroyAPI): queryset = PluginConfig.objects.all() serializer_class = PluginSerializers.PluginConfigSerializer + lookup_field = 'key' def delete(self, request, *args, **kwargs): """Handle DELETE request for a PluginConfig instance. @@ -200,6 +201,7 @@ class PluginUninstall(UpdateAPI): queryset = PluginConfig.objects.all() serializer_class = PluginSerializers.PluginUninstallSerializer permission_classes = [IsSuperuser] + lookup_field = 'key' def perform_update(self, serializer): """Uninstall the plugin.""" @@ -219,6 +221,7 @@ class PluginActivate(UpdateAPI): queryset = PluginConfig.objects.all() serializer_class = PluginSerializers.PluginActivateSerializer permission_classes = [IsSuperuser] + lookup_field = 'key' def get_object(self): """Returns the object for the view.""" @@ -320,10 +323,10 @@ class PluginAllSettingList(APIView): @extend_schema( responses={200: PluginSerializers.PluginSettingSerializer(many=True)} ) - def get(self, request, pk): + def get(self, request, key): """Get all settings for a plugin config.""" # look up the plugin - plugin = check_plugin(None, pk) + plugin = check_plugin(key, None) settings = getattr(plugin, 'settings', {}) @@ -352,21 +355,21 @@ class PluginSettingDetail(RetrieveUpdateAPI): The URL provides the 'slug' of the plugin, and the 'key' of the setting. Both the 'slug' and 'key' must be valid, else a 404 error is raised """ - key = self.kwargs['key'] + setting_key = self.kwargs['setting'] # Look up plugin - plugin = check_plugin( - plugin_slug=self.kwargs.get('plugin'), plugin_pk=self.kwargs.get('pk') - ) + plugin = check_plugin(self.kwargs.pop('key', None), None) settings = getattr(plugin, 'settings', {}) - if key not in settings: + if setting_key not in settings: raise NotFound( - detail=f"Plugin '{plugin.slug}' has no setting matching '{key}'" + detail=f"Plugin '{plugin.slug}' has no setting matching '{setting_key}'" ) - return PluginSetting.get_setting_object(key, plugin=plugin.plugin_config()) + return PluginSetting.get_setting_object( + setting_key, plugin=plugin.plugin_config() + ) # Staff permission required permission_classes = [GlobalSettingsPermissions] @@ -384,7 +387,7 @@ class RegistryStatusView(APIView): @extend_schema(responses={200: PluginSerializers.PluginRegistryStatusSerializer()}) def get(self, request): - """Show registry status information.""" + """Show plugin registry status information.""" error_list = [] for stage, errors in registry.errors.items(): @@ -397,7 +400,8 @@ class RegistryStatusView(APIView): }) result = PluginSerializers.PluginRegistryStatusSerializer({ - 'registry_errors': error_list + 'registry_errors': error_list, + 'active_plugins': PluginConfig.objects.filter(active=True).count(), }).data return Response(result) @@ -410,31 +414,34 @@ plugin_api_urls = [ path( 'plugins/', include([ - # Plugin settings URLs + # Plugin management + path('reload/', PluginReload.as_view(), name='api-plugin-reload'), + path('install/', PluginInstall.as_view(), name='api-plugin-install'), + # Registry status + path( + 'status/', + RegistryStatusView.as_view(), + name='api-plugin-registry-status', + ), path( 'settings/', include([ - re_path( - r'^(?P[-\w]+)/(?P\w+)/', - PluginSettingDetail.as_view(), - name='api-plugin-setting-detail', - ), # Used for admin interface path( '', PluginSettingList.as_view(), name='api-plugin-setting-list' - ), + ) ]), ), - # Detail views for a single PluginConfig item + # Lookup for individual plugins (based on 'key', not 'pk') path( - '/', + '/', include([ path( 'settings/', include([ re_path( - r'^(?P\w+)/', + r'^(?P\w+)/', PluginSettingDetail.as_view(), - name='api-plugin-setting-detail-pk', + name='api-plugin-setting-detail', ), path( '', @@ -443,6 +450,12 @@ plugin_api_urls = [ ), ]), ), + path( + 'metadata/', + MetadataView.as_view(), + {'model': PluginConfig, 'lookup_field': 'key'}, + name='api-plugin-metadata', + ), path( 'activate/', PluginActivate.as_view(), @@ -456,23 +469,6 @@ plugin_api_urls = [ path('', PluginDetail.as_view(), name='api-plugin-detail'), ]), ), - # Metadata - path( - 'metadata/', - MetadataView.as_view(), - {'model': PluginConfig}, - name='api-plugin-metadata', - ), - # Plugin management - path('reload/', PluginReload.as_view(), name='api-plugin-reload'), - path('install/', PluginInstall.as_view(), name='api-plugin-install'), - # Registry status - path( - 'status/', - RegistryStatusView.as_view(), - name='api-plugin-registry-status', - ), - # Anything else path('', PluginList.as_view(), name='api-plugin-list'), ]), ), diff --git a/src/backend/InvenTree/plugin/migrations/0009_alter_pluginconfig_key.py b/src/backend/InvenTree/plugin/migrations/0009_alter_pluginconfig_key.py new file mode 100644 index 0000000000..61de4d323c --- /dev/null +++ b/src/backend/InvenTree/plugin/migrations/0009_alter_pluginconfig_key.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.12 on 2024-05-14 22:40 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('plugin', '0008_pluginconfig_package_name'), + ] + + operations = [ + migrations.AlterField( + model_name='pluginconfig', + name='key', + field=models.CharField(db_index=True, help_text='Key of plugin', max_length=255, unique=True, verbose_name='Key'), + ), + ] diff --git a/src/backend/InvenTree/plugin/models.py b/src/backend/InvenTree/plugin/models.py index 482b0677c1..065eb4f9af 100644 --- a/src/backend/InvenTree/plugin/models.py +++ b/src/backend/InvenTree/plugin/models.py @@ -31,7 +31,11 @@ class PluginConfig(InvenTree.models.MetadataMixin, models.Model): verbose_name_plural = _('Plugin Configurations') key = models.CharField( - unique=True, max_length=255, verbose_name=_('Key'), help_text=_('Key of plugin') + unique=True, + db_index=True, + max_length=255, + verbose_name=_('Key'), + help_text=_('Key of plugin'), ) name = models.CharField( diff --git a/src/backend/InvenTree/plugin/serializers.py b/src/backend/InvenTree/plugin/serializers.py index 507ba20799..b2bca31d4a 100644 --- a/src/backend/InvenTree/plugin/serializers.py +++ b/src/backend/InvenTree/plugin/serializers.py @@ -261,4 +261,10 @@ class PluginRegistryErrorSerializer(serializers.Serializer): class PluginRegistryStatusSerializer(serializers.Serializer): """Serializer for plugin registry status.""" + class Meta: + """Meta for serializer.""" + + fields = ['active_plugins', 'registry_errors'] + + active_plugins = serializers.IntegerField(read_only=True) registry_errors = serializers.ListField(child=PluginRegistryErrorSerializer()) diff --git a/src/backend/InvenTree/plugin/test_api.py b/src/backend/InvenTree/plugin/test_api.py index 51c2d56037..4622e93f3a 100644 --- a/src/backend/InvenTree/plugin/test_api.py +++ b/src/backend/InvenTree/plugin/test_api.py @@ -97,12 +97,10 @@ class PluginDetailAPITest(PluginMixin, InvenTreeAPITestCase): assert plgs is not None self.assertEqual(plgs.active, active) + url = reverse('api-plugin-detail-activate', kwargs={'key': test_plg.key}) + # Should not work - not a superuser - response = self.client.post( - reverse('api-plugin-detail-activate', kwargs={'pk': test_plg.pk}), - {}, - follow=True, - ) + response = self.client.post(url, {}, follow=True) self.assertEqual(response.status_code, 403) # Make user superuser @@ -115,11 +113,7 @@ class PluginDetailAPITest(PluginMixin, InvenTreeAPITestCase): # Activate plugin with detail url assert_plugin_active(self, False) - response = self.client.patch( - reverse('api-plugin-detail-activate', kwargs={'pk': test_plg.pk}), - {}, - follow=True, - ) + response = self.client.patch(url, {}, follow=True) self.assertEqual(response.status_code, 200) assert_plugin_active(self, True) @@ -129,11 +123,7 @@ class PluginDetailAPITest(PluginMixin, InvenTreeAPITestCase): # Activate plugin assert_plugin_active(self, False) - response = self.client.patch( - reverse('api-plugin-detail-activate', kwargs={'pk': test_plg.pk}), - {}, - follow=True, - ) + response = self.client.patch(url, {}, follow=True) self.assertEqual(response.status_code, 200) assert_plugin_active(self, True) @@ -237,7 +227,7 @@ class PluginDetailAPITest(PluginMixin, InvenTreeAPITestCase): cfg = PluginConfig.objects.filter(key='sample').first() assert cfg is not None - url = reverse('api-plugin-detail-activate', kwargs={'pk': cfg.pk}) + url = reverse('api-plugin-detail-activate', kwargs={'key': cfg.key}) self.client.patch(url, {}, expected_code=200) # Valid plugin settings endpoints @@ -246,7 +236,8 @@ class PluginDetailAPITest(PluginMixin, InvenTreeAPITestCase): for key in valid_settings: response = self.get( reverse( - 'api-plugin-setting-detail', kwargs={'plugin': 'sample', 'key': key} + 'api-plugin-setting-detail', + kwargs={'key': 'sample', 'setting': key}, ) ) @@ -256,7 +247,7 @@ class PluginDetailAPITest(PluginMixin, InvenTreeAPITestCase): response = self.get( reverse( 'api-plugin-setting-detail', - kwargs={'plugin': 'sample', 'key': 'INVALID_SETTING'}, + kwargs={'key': 'sample', 'setting': 'INVALID_SETTING'}, ), expected_code=404, ) @@ -265,7 +256,7 @@ class PluginDetailAPITest(PluginMixin, InvenTreeAPITestCase): response = self.get( reverse( 'api-plugin-setting-detail', - kwargs={'plugin': 'sample', 'key': 'PROTECTED_SETTING'}, + kwargs={'key': 'sample', 'setting': 'PROTECTED_SETTING'}, ), expected_code=200, ) @@ -276,7 +267,7 @@ class PluginDetailAPITest(PluginMixin, InvenTreeAPITestCase): response = self.patch( reverse( 'api-plugin-setting-detail', - kwargs={'plugin': 'sample', 'key': 'NUMERICAL_SETTING'}, + kwargs={'key': 'sample', 'setting': 'NUMERICAL_SETTING'}, ), {'value': 456}, expected_code=200, @@ -288,7 +279,7 @@ class PluginDetailAPITest(PluginMixin, InvenTreeAPITestCase): response = self.get( reverse( 'api-plugin-setting-detail', - kwargs={'plugin': 'sample', 'key': 'NUMERICAL_SETTING'}, + kwargs={'key': 'sample', 'setting': 'NUMERICAL_SETTING'}, ), expected_code=200, ) diff --git a/src/backend/InvenTree/templates/js/translated/plugin.js b/src/backend/InvenTree/templates/js/translated/plugin.js index 1c5b3db908..699853f6f4 100644 --- a/src/backend/InvenTree/templates/js/translated/plugin.js +++ b/src/backend/InvenTree/templates/js/translated/plugin.js @@ -114,9 +114,9 @@ function loadPluginTable(table, options={}) { // Check if custom plugins are enabled for this instance if (options.custom && !row.is_builtin && row.is_installed) { if (row.active) { - buttons += makeIconButton('fa-stop-circle icon-red', 'btn-plugin-disable', row.pk, '{% trans "Disable Plugin" %}'); + buttons += makeIconButton('fa-stop-circle icon-red', 'btn-plugin-disable', row.key, '{% trans "Disable Plugin" %}'); } else { - buttons += makeIconButton('fa-play-circle icon-green', 'btn-plugin-enable', row.pk, '{% trans "Enable Plugin" %}'); + buttons += makeIconButton('fa-play-circle icon-green', 'btn-plugin-enable', row.key, '{% trans "Enable Plugin" %}'); } } diff --git a/src/frontend/src/components/settings/SettingList.tsx b/src/frontend/src/components/settings/SettingList.tsx index e06522b2af..bf404578cc 100644 --- a/src/frontend/src/components/settings/SettingList.tsx +++ b/src/frontend/src/components/settings/SettingList.tsx @@ -80,9 +80,9 @@ export function GlobalSettingList({ keys }: { keys: string[] }) { return ; } -export function PluginSettingList({ pluginPk }: { pluginPk: string }) { +export function PluginSettingList({ pluginKey }: { pluginKey: string }) { const pluginSettingsStore = useRef( - createPluginSettingsState({ plugin: pluginPk }) + createPluginSettingsState({ plugin: pluginKey }) ).current; const pluginSettings = useStore(pluginSettingsStore); diff --git a/src/frontend/src/enums/ApiEndpoints.tsx b/src/frontend/src/enums/ApiEndpoints.tsx index e8e5ebc844..43c9371052 100644 --- a/src/frontend/src/enums/ApiEndpoints.tsx +++ b/src/frontend/src/enums/ApiEndpoints.tsx @@ -131,8 +131,8 @@ export enum ApiEndpoints { plugin_registry_status = 'plugins/status/', plugin_install = 'plugins/install/', plugin_reload = 'plugins/reload/', - plugin_activate = 'plugins/:id/activate/', - plugin_uninstall = 'plugins/:id/uninstall/', + plugin_activate = 'plugins/:key/activate/', + plugin_uninstall = 'plugins/:key/uninstall/', // Machine API endpoints machine_types_list = 'machine/types/', diff --git a/src/frontend/src/hooks/UseInstance.tsx b/src/frontend/src/hooks/UseInstance.tsx index 54702b1f3b..c48cc8d07e 100644 --- a/src/frontend/src/hooks/UseInstance.tsx +++ b/src/frontend/src/hooks/UseInstance.tsx @@ -40,12 +40,12 @@ export function useInstance({ const [instance, setInstance] = useState(defaultValue); const instanceQuery = useQuery({ - queryKey: ['instance', endpoint, pk, params], + queryKey: ['instance', endpoint, pk, params, pathParams], queryFn: async () => { if (hasPrimaryKey) { if (pk == null || pk == undefined || pk.length == 0 || pk == '-1') { setInstance(defaultValue); - return null; + return defaultValue; } } @@ -63,7 +63,7 @@ export function useInstance({ return response.data; default: setInstance(defaultValue); - return null; + return defaultValue; } }) .catch((error) => { @@ -72,7 +72,7 @@ export function useInstance({ if (throwError) throw error; - return null; + return defaultValue; }); }, refetchOnMount: refetchOnMount, diff --git a/src/frontend/src/tables/plugin/PluginListTable.tsx b/src/frontend/src/tables/plugin/PluginListTable.tsx index 6112d95017..690797decc 100644 --- a/src/frontend/src/tables/plugin/PluginListTable.tsx +++ b/src/frontend/src/tables/plugin/PluginListTable.tsx @@ -81,10 +81,10 @@ export interface PluginI { } export function PluginDrawer({ - id, + pluginKey, refreshTable }: { - id: string; + pluginKey: string; refreshTable: () => void; }) { const { @@ -93,7 +93,8 @@ export function PluginDrawer({ instanceQuery: { isFetching, error } } = useInstance({ endpoint: ApiEndpoints.plugin_list, - pk: id, + hasPrimaryKey: true, + pk: pluginKey, throwError: true }); @@ -102,15 +103,15 @@ export function PluginDrawer({ refreshInstance(); }, [refreshTable, refreshInstance]); - if (isFetching) { + if (!pluginKey || isFetching) { return ; } - if (error) { + if (!plugin || error) { return ( {(error as any)?.response?.status === 404 ? ( - Plugin with id {id} not found + Plugin with key {pluginKey} not found ) : ( An error occurred while fetching plugin details )} @@ -124,7 +125,7 @@ export function PluginDrawer({ - {plugin && PluginIcon(plugin)} + {plugin && } {plugin?.meta?.human_name ?? plugin?.name ?? '-'} @@ -140,7 +141,7 @@ export function PluginDrawer({ openEditApiForm({ title: t`Edit plugin`, url: ApiEndpoints.plugin_list, - pk: id, + pathParams: { key: pluginKey }, fields: { active: {} }, @@ -224,13 +225,13 @@ export function PluginDrawer({ - {plugin && plugin.active && ( + {plugin && plugin?.active && ( <Trans>Plugin settings</Trans> - + )} @@ -241,9 +242,9 @@ export function PluginDrawer({ /** * Construct an indicator icon for a single plugin */ -function PluginIcon(plugin: PluginI) { - if (plugin.is_installed) { - if (plugin.active) { +function PluginIcon({ plugin }: { plugin: PluginI }) { + if (plugin?.is_installed) { + if (plugin?.active) { return ( @@ -287,11 +288,13 @@ export default function PluginListTable() { title: t`Plugin`, sortable: true, render: function (record: any) { - // TODO: Add link to plugin detail page - // TODO: Add custom badges + if (!record) { + return; + } + return ( - + {record.name} ); @@ -331,7 +334,7 @@ export default function PluginListTable() { ); const activatePlugin = useCallback( - (plugin_id: number, plugin_name: string, active: boolean) => { + (plugin_key: string, plugin_name: string, active: boolean) => { modals.openConfirmModal({ title: ( @@ -366,7 +369,9 @@ export default function PluginListTable() { confirm: t`Confirm` }, onConfirm: () => { - let url = apiUrl(ApiEndpoints.plugin_activate, plugin_id); + let url = apiUrl(ApiEndpoints.plugin_activate, null, { + key: plugin_key + }); const id = 'plugin-activate'; @@ -424,7 +429,7 @@ export default function PluginListTable() { color: 'red', icon: , onClick: () => { - activatePlugin(record.pk, record.name, false); + activatePlugin(record.key, record.name, false); } }); } else { @@ -433,7 +438,7 @@ export default function PluginListTable() { color: 'green', icon: , onClick: () => { - activatePlugin(record.pk, record.name, true); + activatePlugin(record.key, record.name, true); } }); } @@ -464,7 +469,7 @@ export default function PluginListTable() { color: 'red', icon: , onClick: () => { - setSelectedPlugin(record.pk); + setSelectedPlugin(record.key); uninstallPluginModal.open(); }, disabled: plugins_install_disabled || false @@ -478,7 +483,7 @@ export default function PluginListTable() { color: 'red', icon: , onClick: () => { - setSelectedPlugin(record.pk); + setSelectedPlugin(record.key); deletePluginModal.open(); } }); @@ -519,12 +524,12 @@ export default function PluginListTable() { } }); - const [selectedPlugin, setSelectedPlugin] = useState(-1); + const [selectedPlugin, setSelectedPlugin] = useState(''); const uninstallPluginModal = useEditApiFormModal({ title: t`Uninstall Plugin`, url: ApiEndpoints.plugin_uninstall, - pk: selectedPlugin, + pathParams: { key: selectedPlugin }, fetchInitialData: false, timeout: 30000, fields: { @@ -556,7 +561,7 @@ export default function PluginListTable() { const deletePluginModal = useDeleteApiFormModal({ url: ApiEndpoints.plugin_list, - pk: selectedPlugin, + pathParams: { key: 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?` @@ -618,9 +623,14 @@ export default function PluginListTable() { { - if (!id) return false; - return ; + renderContent={(pluginKey) => { + if (!pluginKey) return; + return ( + + ); }} /> navigate(`${plugin.pk}/`), + onRowClick: (plugin) => navigate(`${plugin.key}/`), tableActions: tableActions, tableFilters: [ {