Repository: inventree/InvenTree

Issue:
[FR] :checkered_flag: Add API endpoint to activate plugins
### Please verify that this feature request has NOT been suggested before.

- [X] I checked and didn't find similar feature request

### Problem statement

There is currently no endpoint to activate a plugin by a key

### Suggested solution

Add an endpoint `/api/plugin/activate` that is supplied with the key/slug of the plugin to enable them

### Describe alternatives you've considered

You could run a patch on the detail endpoint but a separate endpoint would be nice

### Examples of other systems

_No response_

### Do you want to develop this?

- [X] I want to develop this.


Discussion:
@SchrodingersGat what do you think? Also: schould we maybe relocate the detail endpoints for plugins to /plugins instead of /plugin - which also exposes plugins with UrlMixins

Resolving patch:
diff --git a/InvenTree/InvenTree/metadata.py b/InvenTree/InvenTree/metadata.py
--- a/InvenTree/InvenTree/metadata.py
+++ b/InvenTree/InvenTree/metadata.py
@@ -256,7 +256,7 @@ def get_field_info(self, field):
             if isinstance(field, serializers.PrimaryKeyRelatedField):
                 model = field.queryset.model
             else:
-                logger.debug("Could not extract model for:", field_info['label'], '->', field)
+                logger.debug("Could not extract model for:", field_info.get('label'), '->', field)
                 model = None
 
             if model:
diff --git a/InvenTree/plugin/api.py b/InvenTree/plugin/api.py
--- a/InvenTree/plugin/api.py
+++ b/InvenTree/plugin/api.py
@@ -17,7 +17,6 @@
 from plugin.base.locate.api import LocatePluginView
 from plugin.models import PluginConfig, PluginSetting
 from plugin.plugin import InvenTreePlugin
-from plugin.registry import registry
 
 
 class PluginList(ListAPI):
@@ -130,6 +129,12 @@ class PluginActivate(UpdateAPI):
     serializer_class = PluginSerializers.PluginConfigEmptySerializer
     permission_classes = [IsSuperuser, ]
 
+    def get_object(self):
+        """Returns the object for the view."""
+        if self.request.data.get('pk', None):
+            return self.queryset.get(pk=self.request.data.get('pk'))
+        return super().get_object()
+
     def perform_update(self, serializer):
         """Activate the plugin."""
         instance = serializer.instance
@@ -161,11 +166,12 @@ class PluginSettingList(ListAPI):
     ]
 
 
-def check_plugin(plugin_slug: str) -> InvenTreePlugin:
+def check_plugin(plugin_slug: str, plugin_pk: int) -> InvenTreePlugin:
     """Check that a plugin for the provided slug exsists and get the config.
 
     Args:
         plugin_slug (str): Slug for plugin.
+        plugin_pk (int): Primary key for plugin.
 
     Raises:
         NotFound: If plugin is not installed
@@ -175,22 +181,33 @@ def check_plugin(plugin_slug: str) -> InvenTreePlugin:
     Returns:
         InvenTreePlugin: The config object for the provided plugin.
     """
-    # Check that the 'plugin' specified is valid!
-    if not PluginConfig.objects.filter(key=plugin_slug).exists():
-        raise NotFound(detail=f"Plugin '{plugin_slug}' not installed")
-
-    # Get the list of settings available for the specified plugin
-    plugin = registry.get_plugin(plugin_slug)
-
-    if plugin is None:
+    # Make sure that a plugin reference is specified
+    if plugin_slug is None and plugin_pk is None:
+        raise NotFound(detail="Plugin not specified")
+
+    # Define filter
+    filter = {}
+    if plugin_slug:
+        filter['key'] = plugin_slug
+    elif plugin_pk:
+        filter['pk'] = plugin_pk
+    ref = plugin_slug or plugin_pk
+
+    # Check that the 'plugin' specified is valid
+    try:
+        plugin_cgf = PluginConfig.objects.get(**filter)
+    except PluginConfig.DoesNotExist:
+        raise NotFound(detail=f"Plugin '{ref}' not installed")
+
+    if plugin_cgf is None:
         # This only occurs if the plugin mechanism broke
-        raise NotFound(detail=f"Plugin '{plugin_slug}' not found")  # pragma: no cover
+        raise NotFound(detail=f"Plugin '{ref}' not found")  # pragma: no cover
 
     # Check that the plugin is activated
-    if not plugin.is_active():
-        raise NotFound(detail=f"Plugin '{plugin_slug}' is not active")
+    if not plugin_cgf.active:
+        raise NotFound(detail=f"Plugin '{ref}' is not active")
 
-    return plugin
+    return plugin_cgf.plugin
 
 
 class PluginSettingDetail(RetrieveUpdateAPI):
@@ -208,16 +225,15 @@ def get_object(self):
         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
         """
-        plugin_slug = self.kwargs['plugin']
         key = self.kwargs['key']
 
         # Look up plugin
-        plugin = check_plugin(plugin_slug)
+        plugin = check_plugin(plugin_slug=self.kwargs.get('plugin'), plugin_pk=self.kwargs.get('pk'))
 
         settings = getattr(plugin, 'settings', {})
 
         if key not in settings:
-            raise NotFound(detail=f"Plugin '{plugin_slug}' has no setting matching '{key}'")
+            raise NotFound(detail=f"Plugin '{plugin.slug}' has no setting matching '{key}'")
 
         return PluginSetting.get_setting_object(key, plugin=plugin)
 
@@ -234,12 +250,13 @@ def get_object(self):
     re_path(r'^plugins/', include([
         # Plugin settings URLs
         re_path(r'^settings/', include([
-            re_path(r'^(?P<plugin>\w+)/(?P<key>\w+)/', PluginSettingDetail.as_view(), name='api-plugin-setting-detail'),
+            re_path(r'^(?P<plugin>\w+)/(?P<key>\w+)/', PluginSettingDetail.as_view(), name='api-plugin-setting-detail'),    # Used for admin interface
             re_path(r'^.*$', PluginSettingList.as_view(), name='api-plugin-setting-list'),
         ])),
 
         # Detail views for a single PluginConfig item
         re_path(r'^(?P<pk>\d+)/', include([
+            re_path(r'^settings/(?P<key>\w+)/', PluginSettingDetail.as_view(), name='api-plugin-setting-detail-pk'),
             re_path(r'^activate/', PluginActivate.as_view(), name='api-plugin-detail-activate'),
             re_path(r'^.*$', PluginDetail.as_view(), name='api-plugin-detail'),
         ])),
diff --git a/InvenTree/plugin/models.py b/InvenTree/plugin/models.py
--- a/InvenTree/plugin/models.py
+++ b/InvenTree/plugin/models.py
@@ -123,13 +123,15 @@ def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
         self.__org_active = self.active
 
-        # append settings from registry
+        # Append settings from registry
         plugin = registry.plugins_full.get(self.key, None)
 
         def get_plugin_meta(name):
-            if plugin:
-                return str(getattr(plugin, name, None))
-            return None
+            if not plugin:
+                return None
+            if not self.active:
+                return _('Unvailable')
+            return str(getattr(plugin, name, None))
 
         self.meta = {
             key: get_plugin_meta(key) for key in ['slug', 'human_name', 'description', 'author',


Test patch:
diff --git a/InvenTree/plugin/test_api.py b/InvenTree/plugin/test_api.py
--- a/InvenTree/plugin/test_api.py
+++ b/InvenTree/plugin/test_api.py
@@ -2,7 +2,11 @@
 
 from django.urls import reverse
 
+from rest_framework.exceptions import NotFound
+
 from InvenTree.api_tester import InvenTreeAPITestCase, PluginMixin
+from plugin.api import check_plugin
+from plugin.models import PluginConfig
 
 
 class PluginDetailAPITest(PluginMixin, InvenTreeAPITestCase):
@@ -86,6 +90,42 @@ def test_plugin_install(self):
 
         self.assertEqual(data['confirm'][0].title().upper(), 'Installation not confirmed'.upper())
 
+    def test_plugin_activate(self):
+        """Test the plugin activate."""
+
+        test_plg = self.plugin_confs.first()
+
+        def assert_plugin_active(self, active):
+            self.assertEqual(PluginConfig.objects.all().first().active, active)
+
+        # Should not work - not a superuser
+        response = self.client.post(reverse('api-plugin-activate'), {}, follow=True)
+        self.assertEqual(response.status_code, 403)
+
+        # Make user superuser
+        self.user.is_superuser = True
+        self.user.save()
+
+        # Deactivate plugin
+        test_plg.active = False
+        test_plg.save()
+
+        # Activate plugin with detail url
+        assert_plugin_active(self, False)
+        response = self.client.patch(reverse('api-plugin-detail-activate', kwargs={'pk': test_plg.id}), {}, follow=True)
+        self.assertEqual(response.status_code, 200)
+        assert_plugin_active(self, True)
+
+        # Deactivate plugin
+        test_plg.active = False
+        test_plg.save()
+
+        # Activate plugin
+        assert_plugin_active(self, False)
+        response = self.client.patch(reverse('api-plugin-activate'), {'pk': test_plg.pk}, follow=True)
+        self.assertEqual(response.status_code, 200)
+        assert_plugin_active(self, True)
+
     def test_admin_action(self):
         """Test the PluginConfig action commands."""
         url = reverse('admin:plugin_pluginconfig_changelist')
@@ -135,3 +175,26 @@ def test_model(self):
             plg_inactive.active = True
             plg_inactive.save()
         self.assertEqual(cm.warning.args[0], 'A reload was triggered')
+
+    def test_check_plugin(self):
+        """Test check_plugin function."""
+
+        # No argument
+        with self.assertRaises(NotFound) as exc:
+            check_plugin(plugin_slug=None, plugin_pk=None)
+        self.assertEqual(str(exc.exception.detail), 'Plugin not specified')
+
+        # Wrong with slug
+        with self.assertRaises(NotFound) as exc:
+            check_plugin(plugin_slug='123abc', plugin_pk=None)
+        self.assertEqual(str(exc.exception.detail), "Plugin '123abc' not installed")
+
+        # Wrong with pk
+        with self.assertRaises(NotFound) as exc:
+            check_plugin(plugin_slug=None, plugin_pk='123')
+        self.assertEqual(str(exc.exception.detail), "Plugin '123' not installed")
+
+        # Not active
+        with self.assertRaises(NotFound) as exc:
+            check_plugin(plugin_slug='inventreebarcode', plugin_pk=None)
+        self.assertEqual(str(exc.exception.detail), "Plugin 'inventreebarcode' is not active")