From 547db3322fb3f67e3c6f9fbef329c7c6bd73501d Mon Sep 17 00:00:00 2001
From: Oliver <oliver.henry.walters@gmail.com>
Date: Sat, 1 Jan 2022 22:00:43 +1100
Subject: [PATCH 1/9] Adds new "PluginSetting" class

- Adds settings which are unique to a particular plugin
---
 InvenTree/common/models.py | 168 ++++++++++++++++++-------------------
 InvenTree/plugin/admin.py  |  12 ++-
 InvenTree/plugin/models.py |  52 ++++++++++--
 3 files changed, 136 insertions(+), 96 deletions(-)

diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py
index c514d7c4a9..0babfaa8e3 100644
--- a/InvenTree/common/models.py
+++ b/InvenTree/common/models.py
@@ -65,13 +65,13 @@ class BaseInvenTreeSetting(models.Model):
 
         self.key = str(self.key).upper()
 
-        self.clean()
+        self.clean(**kwargs)
         self.validate_unique()
 
         super().save()
 
     @classmethod
-    def allValues(cls, user=None, exclude_hidden=False):
+    def allValues(cls, user=None, plugin=None, exclude_hidden=False):
         """
         Return a dict of "all" defined global settings.
 
@@ -82,9 +82,14 @@ class BaseInvenTreeSetting(models.Model):
 
         results = cls.objects.all()
 
+        # Optionally filter by user
         if user is not None:
             results = results.filter(user=user)
 
+        # Optionally filter by plugin
+        if plugin is not None:
+            results = results.filter(plugin=plugin)
+
         # Query the database
         settings = {}
 
@@ -123,98 +128,92 @@ class BaseInvenTreeSetting(models.Model):
         return settings
 
     @classmethod
-    def get_setting_name(cls, key):
+    def get_setting_definition(cls, key, **kwargs):
+        """
+        Return the 'definition' of a particular settings value, as a dict object.
+
+        - The 'settings' dict can be passed as a kwarg
+        - If not passed, look for cls.GLOBAL_SETTINGS
+        - Returns an empty dict if the key is not found
+        """
+
+        settings = kwargs.get('settings', cls.GLOBAL_SETTINGS)
+
+        key = str(key).strip().upper()
+
+        if settings is not None and key in settings:
+            return settings[key]
+        else:
+            return {}
+
+    @classmethod
+    def get_setting_name(cls, key, **kwargs):
         """
         Return the name of a particular setting.
 
         If it does not exist, return an empty string.
         """
 
-        key = str(key).strip().upper()
-
-        if key in cls.GLOBAL_SETTINGS:
-            setting = cls.GLOBAL_SETTINGS[key]
-            return setting.get('name', '')
-        else:
-            return ''
+        setting = cls.get_setting_definition(key, **kwargs)
+        return setting.get('name', '')
 
     @classmethod
-    def get_setting_description(cls, key):
+    def get_setting_description(cls, key, **kwargs):
         """
         Return the description for a particular setting.
 
         If it does not exist, return an empty string.
         """
 
-        key = str(key).strip().upper()
+        setting = cls.get_setting_definition(key, **kwargs)
 
-        if key in cls.GLOBAL_SETTINGS:
-            setting = cls.GLOBAL_SETTINGS[key]
-            return setting.get('description', '')
-        else:
-            return ''
+        return setting.get('description', '')
 
     @classmethod
-    def get_setting_units(cls, key):
+    def get_setting_units(cls, key, **kwargs):
         """
         Return the units for a particular setting.
 
         If it does not exist, return an empty string.
         """
 
-        key = str(key).strip().upper()
+        setting = cls.get_setting_definition(key, **kwargs)
 
-        if key in cls.GLOBAL_SETTINGS:
-            setting = cls.GLOBAL_SETTINGS[key]
-            return setting.get('units', '')
-        else:
-            return ''
+        return setting.get('units', '')
 
     @classmethod
-    def get_setting_validator(cls, key):
+    def get_setting_validator(cls, key, **kwargs):
         """
         Return the validator for a particular setting.
 
         If it does not exist, return None
         """
 
-        key = str(key).strip().upper()
+        setting = cls.get_setting_definition(key, **kwargs)
 
-        if key in cls.GLOBAL_SETTINGS:
-            setting = cls.GLOBAL_SETTINGS[key]
-            return setting.get('validator', None)
-        else:
-            return None
+        return setting.get('validator', None)
 
     @classmethod
-    def get_setting_default(cls, key):
+    def get_setting_default(cls, key, **kwargs):
         """
         Return the default value for a particular setting.
 
         If it does not exist, return an empty string
         """
 
-        key = str(key).strip().upper()
+        setting = cls.get_setting_definition(key, **kwargs)
 
-        if key in cls.GLOBAL_SETTINGS:
-            setting = cls.GLOBAL_SETTINGS[key]
-            return setting.get('default', '')
-        else:
-            return ''
+        return setting.get('default', '')
 
     @classmethod
-    def get_setting_choices(cls, key):
+    def get_setting_choices(cls, key, **kwargs):
         """
         Return the validator choices available for a particular setting.
         """
 
-        key = str(key).strip().upper()
+        setting = cls.get_setting_definition(key, **kwargs)
 
-        if key in cls.GLOBAL_SETTINGS:
-            setting = cls.GLOBAL_SETTINGS[key]
-            choices = setting.get('choices', None)
-        else:
-            choices = None
+        choices = setting.get('choices', None)
 
         if callable(choices):
             # Evaluate the function (we expect it will return a list of tuples...)
@@ -237,8 +236,20 @@ class BaseInvenTreeSetting(models.Model):
 
         key = str(key).strip().upper()
 
+        settings = cls.objects.all()
+
+        user = kwargs.get('user', None)
+
+        if user is not None:
+            settings = settings.filter(user=user)
+
+        plugin = kwargs.get('plugin', None)
+
+        if plugin is not None:
+            settings = settings.filter(plugin=plugin)
+
         try:
-            setting = cls.objects.filter(**cls.get_filters(key, **kwargs)).first()
+            setting = settings.filter(**cls.get_filters(key, **kwargs)).first()
         except (ValueError, cls.DoesNotExist):
             setting = None
         except (IntegrityError, OperationalError):
@@ -247,7 +258,12 @@ class BaseInvenTreeSetting(models.Model):
         # Setting does not exist! (Try to create it)
         if not setting:
 
-            setting = cls(key=key, value=cls.get_setting_default(key), **kwargs)
+            # Attempt to create a new settings object
+            setting = cls(
+                key=key,
+                value=cls.get_setting_default(key, **kwargs),
+                **kwargs
+            )
 
             try:
                 # Wrap this statement in "atomic", so it can be rolled back if it fails
@@ -259,21 +275,6 @@ class BaseInvenTreeSetting(models.Model):
 
         return setting
 
-    @classmethod
-    def get_setting_pk(cls, key):
-        """
-        Return the primary-key value for a given setting.
-
-        If the setting does not exist, return None
-        """
-
-        setting = cls.get_setting_object(cls)
-
-        if setting:
-            return setting.pk
-        else:
-            return None
-
     @classmethod
     def get_setting(cls, key, backup_value=None, **kwargs):
         """
@@ -283,18 +284,19 @@ class BaseInvenTreeSetting(models.Model):
 
         # If no backup value is specified, atttempt to retrieve a "default" value
         if backup_value is None:
-            backup_value = cls.get_setting_default(key)
+            backup_value = cls.get_setting_default(key, **kwargs)
 
         setting = cls.get_setting_object(key, **kwargs)
 
         if setting:
             value = setting.value
 
-            # If the particular setting is defined as a boolean, cast the value to a boolean
-            if setting.is_bool():
+            # Cast to boolean if necessary
+            if setting.is_bool(**kwargs):
                 value = InvenTree.helpers.str2bool(value)
 
-            if setting.is_int():
+            # Cast to integer if necessary
+            if setting.is_int(**kwargs):
                 try:
                     value = int(value)
                 except (ValueError, TypeError):
@@ -357,7 +359,7 @@ class BaseInvenTreeSetting(models.Model):
     def units(self):
         return self.__class__.get_setting_units(self.key)
 
-    def clean(self):
+    def clean(self, **kwargs):
         """
         If a validator (or multiple validators) are defined for a particular setting key,
         run them against the 'value' field.
@@ -365,7 +367,7 @@ class BaseInvenTreeSetting(models.Model):
 
         super().clean()
 
-        validator = self.__class__.get_setting_validator(self.key)
+        validator = self.__class__.get_setting_validator(self.key, **kwargs)
 
         if self.is_bool():
             self.value = InvenTree.helpers.str2bool(self.value)
@@ -459,12 +461,12 @@ class BaseInvenTreeSetting(models.Model):
 
         return [opt[0] for opt in choices]
 
-    def is_bool(self):
+    def is_bool(self, **kwargs):
         """
         Check if this setting is required to be a boolean value
         """
 
-        validator = self.__class__.get_setting_validator(self.key)
+        validator = self.__class__.get_setting_validator(self.key, **kwargs)
 
         return self.__class__.validator_is_bool(validator)
 
@@ -477,15 +479,15 @@ class BaseInvenTreeSetting(models.Model):
 
         return InvenTree.helpers.str2bool(self.value)
 
-    def setting_type(self):
+    def setting_type(self, **kwargs):
         """
         Return the field type identifier for this setting object
         """
 
-        if self.is_bool():
+        if self.is_bool(**kwargs):
             return 'boolean'
 
-        elif self.is_int():
+        elif self.is_int(**kwargs):
             return 'integer'
 
         else:
@@ -504,12 +506,12 @@ class BaseInvenTreeSetting(models.Model):
 
         return False
 
-    def is_int(self):
+    def is_int(self, **kwargs):
         """
         Check if the setting is required to be an integer value:
         """
 
-        validator = self.__class__.get_setting_validator(self.key)
+        validator = self.__class__.get_setting_validator(self.key, **kwargs)
 
         return self.__class__.validator_is_int(validator)
 
@@ -541,17 +543,14 @@ class BaseInvenTreeSetting(models.Model):
         return value
 
     @classmethod
-    def is_protected(cls, key):
+    def is_protected(cls, key, **kwargs):
         """
         Check if the setting value is protected
         """
 
-        key = str(key).strip().upper()
+        setting = cls.get_setting_definition(key, **kwargs)
 
-        if key in cls.GLOBAL_SETTINGS:
-            return cls.GLOBAL_SETTINGS[key].get('protected', False)
-        else:
-            return False
+        return setting.get('protected', False)
 
 
 def settings_group_options():
@@ -977,13 +976,6 @@ class InvenTreeSetting(BaseInvenTreeSetting):
             'validator': bool,
             'requires_restart': True,
         },
-        'ENABLE_PLUGINS_GLOBALSETTING': {
-            'name': _('Enable global setting integration'),
-            'description': _('Enable plugins to integrate into inventree global settings'),
-            'default': False,
-            'validator': bool,
-            'requires_restart': True,
-        },
         'ENABLE_PLUGINS_APP': {
             'name': _('Enable app integration'),
             'description': _('Enable plugins to add apps'),
diff --git a/InvenTree/plugin/admin.py b/InvenTree/plugin/admin.py
index 3a96a4b9ea..4bd4664212 100644
--- a/InvenTree/plugin/admin.py
+++ b/InvenTree/plugin/admin.py
@@ -35,12 +35,20 @@ def plugin_deactivate(modeladmin, request, queryset):
     plugin_update(queryset, False)
 
 
+class PluginSettingInline(admin.TabularInline):
+    """
+    Inline admin class for PluginSetting
+    """
+
+    model = models.PluginSetting    
+
+
 class PluginConfigAdmin(admin.ModelAdmin):
     """Custom admin with restricted id fields"""
     readonly_fields = ["key", "name", ]
-    list_display = ['active', '__str__', 'key', 'name', ]
+    list_display = ['name', 'key', '__str__', 'active', ]
     list_filter = ['active']
     actions = [plugin_activate, plugin_deactivate, ]
-
+    inlines = [PluginSettingInline,]
 
 admin.site.register(models.PluginConfig, PluginConfigAdmin)
diff --git a/InvenTree/plugin/models.py b/InvenTree/plugin/models.py
index 93c6335497..b001798544 100644
--- a/InvenTree/plugin/models.py
+++ b/InvenTree/plugin/models.py
@@ -8,16 +8,17 @@ from __future__ import unicode_literals
 from django.utils.translation import gettext_lazy as _
 from django.db import models
 
+import common.models
+
 from plugin import plugin_reg
 
 
 class PluginConfig(models.Model):
-    """ A PluginConfig object holds settings for plugins.
-
-    It is used to designate a Part as 'subscribed' for a given User.
+    """
+    A PluginConfig object holds settings for plugins.
 
     Attributes:
-        key: slug of the plugin - must be unique
+        key: slug of the plugin (this must be unique across all installed plugins!)
         name: PluginName of the plugin - serves for a manual double check  if the right plugin is used
         active: Should the plugin be loaded?
     """
@@ -63,7 +64,10 @@ class PluginConfig(models.Model):
     # functions
 
     def __init__(self, *args, **kwargs):
-        """override to set original state of"""
+        """
+        Override to set original state of the plugin-config instance
+        """
+
         super().__init__(*args, **kwargs)
         self.__org_active = self.active
 
@@ -82,7 +86,9 @@ class PluginConfig(models.Model):
         }
 
     def save(self, force_insert=False, force_update=False, *args, **kwargs):
-        """extend save method to reload plugins if the 'active' status changes"""
+        """
+        Extend save method to reload plugins if the 'active' status changes
+        """
         reload = kwargs.pop('no_reload', False)  # check if no_reload flag is set
 
         ret = super().save(force_insert, force_update, *args, **kwargs)
@@ -95,3 +101,37 @@ class PluginConfig(models.Model):
                 plugin_reg.reload_plugins()
 
         return ret
+
+
+class PluginSetting(common.models.BaseInvenTreeSetting):
+    """
+    This model represents settings for individual plugins
+    """
+
+    class Meta:
+        unique_together = [
+            ('plugin', 'key'),
+        ]
+
+    @classmethod
+    def get_filters(cls, key, **kwargs):
+        """
+        Override filters method to ensure settings are filtered by plugin id
+        """
+
+        filters = super().get_filters(key, **kwargs)
+
+        plugin = kwargs.get('plugin', None)
+
+        if plugin:
+            filters['plugin'] = plugin
+
+        return filters
+
+    plugin = models.ForeignKey(
+        PluginConfig,
+        related_name='settings',
+        null=False,
+        verbose_name=_('Plugin'),
+        on_delete=models.CASCADE,
+    )

From 7f08c75a08047ccb8a3edbcbe9b06a86f3046d4f Mon Sep 17 00:00:00 2001
From: Oliver <oliver.henry.walters@gmail.com>
Date: Sat, 1 Jan 2022 22:00:58 +1100
Subject: [PATCH 2/9] Add missing migration file

---
 .../plugin/migrations/0003_pluginsetting.py   | 26 +++++++++++++++++++
 1 file changed, 26 insertions(+)
 create mode 100644 InvenTree/plugin/migrations/0003_pluginsetting.py

diff --git a/InvenTree/plugin/migrations/0003_pluginsetting.py b/InvenTree/plugin/migrations/0003_pluginsetting.py
new file mode 100644
index 0000000000..83e744fa6b
--- /dev/null
+++ b/InvenTree/plugin/migrations/0003_pluginsetting.py
@@ -0,0 +1,26 @@
+# Generated by Django 3.2.10 on 2022-01-01 10:52
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('plugin', '0002_alter_pluginconfig_options'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='PluginSetting',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('key', models.CharField(help_text='Settings key (must be unique - case insensitive', max_length=50)),
+                ('value', models.CharField(blank=True, help_text='Settings value', max_length=200)),
+                ('plugin', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='settings', to='plugin.pluginconfig', verbose_name='Plugin')),
+            ],
+            options={
+                'unique_together': {('plugin', 'key')},
+            },
+        ),
+    ]

From 7cb029e7c39773b6567ad9437ff73fc1d04fb6ee Mon Sep 17 00:00:00 2001
From: Oliver <oliver.henry.walters@gmail.com>
Date: Sat, 1 Jan 2022 22:20:21 +1100
Subject: [PATCH 3/9] Refactor: Rename "GLOBAL_SETTINGS" to "SETTINGS"

---
 .../management/commands/clean_settings.py       | 17 +++++++++++------
 InvenTree/common/models.py                      | 16 ++++++++--------
 InvenTree/common/tests.py                       |  8 ++++----
 InvenTree/plugin/admin.py                       |  5 +++--
 InvenTree/plugin/registry.py                    |  4 ++--
 InvenTree/users/models.py                       |  3 ++-
 6 files changed, 30 insertions(+), 23 deletions(-)

diff --git a/InvenTree/InvenTree/management/commands/clean_settings.py b/InvenTree/InvenTree/management/commands/clean_settings.py
index e0fd09e6c7..283416de29 100644
--- a/InvenTree/InvenTree/management/commands/clean_settings.py
+++ b/InvenTree/InvenTree/management/commands/clean_settings.py
@@ -2,9 +2,14 @@
 Custom management command to cleanup old settings that are not defined anymore
 """
 
+import logging
+
 from django.core.management.base import BaseCommand
 
 
+logger = logging.getLogger('inventree')
+
+
 class Command(BaseCommand):
     """
     Cleanup old (undefined) settings in the database
@@ -12,27 +17,27 @@ class Command(BaseCommand):
 
     def handle(self, *args, **kwargs):
 
-        print("Collecting settings")
+        logger.info("Collecting settings")
         from common.models import InvenTreeSetting, InvenTreeUserSetting
 
         # general settings
         db_settings = InvenTreeSetting.objects.all()
-        model_settings = InvenTreeSetting.GLOBAL_SETTINGS
+        model_settings = InvenTreeSetting.SETTINGS
 
         # check if key exist and delete if not
         for setting in db_settings:
             if setting.key not in model_settings:
                 setting.delete()
-                print(f"deleted setting '{setting.key}'")
+                logger.info(f"deleted setting '{setting.key}'")
 
         # user settings
         db_settings = InvenTreeUserSetting.objects.all()
-        model_settings = InvenTreeUserSetting.GLOBAL_SETTINGS
+        model_settings = InvenTreeUserSetting.SETTINGS
 
         # check if key exist and delete if not
         for setting in db_settings:
             if setting.key not in model_settings:
                 setting.delete()
-                print(f"deleted user setting '{setting.key}'")
+                logger.info(f"deleted user setting '{setting.key}'")
 
-        print("checked all settings")
+        logger.info("checked all settings")
diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py
index 0babfaa8e3..ecddd17de8 100644
--- a/InvenTree/common/models.py
+++ b/InvenTree/common/models.py
@@ -53,7 +53,7 @@ class BaseInvenTreeSetting(models.Model):
     single values (e.g. one-off settings values).
     """
 
-    GLOBAL_SETTINGS = {}
+    SETTINGS = {}
 
     class Meta:
         abstract = True
@@ -98,13 +98,13 @@ class BaseInvenTreeSetting(models.Model):
                 settings[setting.key.upper()] = setting.value
 
         # Specify any "default" values which are not in the database
-        for key in cls.GLOBAL_SETTINGS.keys():
+        for key in cls.SETTINGS.keys():
 
             if key.upper() not in settings:
                 settings[key.upper()] = cls.get_setting_default(key)
 
             if exclude_hidden:
-                hidden = cls.GLOBAL_SETTINGS[key].get('hidden', False)
+                hidden = cls.SETTINGS[key].get('hidden', False)
 
                 if hidden:
                     # Remove hidden items
@@ -133,11 +133,11 @@ class BaseInvenTreeSetting(models.Model):
         Return the 'definition' of a particular settings value, as a dict object.
 
         - The 'settings' dict can be passed as a kwarg
-        - If not passed, look for cls.GLOBAL_SETTINGS
+        - If not passed, look for cls.SETTINGS
         - Returns an empty dict if the key is not found
         """
 
-        settings = kwargs.get('settings', cls.GLOBAL_SETTINGS)
+        settings = kwargs.get('settings', cls.SETTINGS)
 
         key = str(key).strip().upper()
 
@@ -594,7 +594,7 @@ class InvenTreeSetting(BaseInvenTreeSetting):
     The keys must be upper-case
     """
 
-    GLOBAL_SETTINGS = {
+    SETTINGS = {
 
         'SERVER_RESTART_REQUIRED': {
             'name': _('Restart required'),
@@ -1009,7 +1009,7 @@ class InvenTreeSetting(BaseInvenTreeSetting):
         Return True if this setting requires a server restart after changing
         """
 
-        options = InvenTreeSetting.GLOBAL_SETTINGS.get(self.key, None)
+        options = InvenTreeSetting.SETTINGS.get(self.key, None)
 
         if options:
             return options.get('requires_restart', False)
@@ -1022,7 +1022,7 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
     An InvenTreeSetting object with a usercontext
     """
 
-    GLOBAL_SETTINGS = {
+    SETTINGS = {
         'HOMEPAGE_PART_STARRED': {
             'name': _('Show subscribed parts'),
             'description': _('Show subscribed parts on the homepage'),
diff --git a/InvenTree/common/tests.py b/InvenTree/common/tests.py
index c20dc5d126..8bd5b6ffba 100644
--- a/InvenTree/common/tests.py
+++ b/InvenTree/common/tests.py
@@ -49,9 +49,9 @@ class SettingsTest(TestCase):
         - Ensure that every global setting has a description.
         """
 
-        for key in InvenTreeSetting.GLOBAL_SETTINGS.keys():
+        for key in InvenTreeSetting.SETTINGS.keys():
 
-            setting = InvenTreeSetting.GLOBAL_SETTINGS[key]
+            setting = InvenTreeSetting.SETTINGS[key]
 
             name = setting.get('name', None)
 
@@ -64,14 +64,14 @@ class SettingsTest(TestCase):
                 raise ValueError(f'Missing GLOBAL_SETTING description for {key}')
 
             if not key == key.upper():
-                raise ValueError(f"GLOBAL_SETTINGS key '{key}' is not uppercase")
+                raise ValueError(f"SETTINGS key '{key}' is not uppercase")
 
     def test_defaults(self):
         """
         Populate the settings with default values
         """
 
-        for key in InvenTreeSetting.GLOBAL_SETTINGS.keys():
+        for key in InvenTreeSetting.SETTINGS.keys():
 
             value = InvenTreeSetting.get_setting_default(key)
 
diff --git a/InvenTree/plugin/admin.py b/InvenTree/plugin/admin.py
index 4bd4664212..839dac305a 100644
--- a/InvenTree/plugin/admin.py
+++ b/InvenTree/plugin/admin.py
@@ -40,7 +40,7 @@ class PluginSettingInline(admin.TabularInline):
     Inline admin class for PluginSetting
     """
 
-    model = models.PluginSetting    
+    model = models.PluginSetting
 
 
 class PluginConfigAdmin(admin.ModelAdmin):
@@ -49,6 +49,7 @@ class PluginConfigAdmin(admin.ModelAdmin):
     list_display = ['name', 'key', '__str__', 'active', ]
     list_filter = ['active']
     actions = [plugin_activate, plugin_deactivate, ]
-    inlines = [PluginSettingInline,]
+    inlines = [PluginSettingInline, ]
+
 
 admin.site.register(models.PluginConfig, PluginConfigAdmin)
diff --git a/InvenTree/plugin/registry.py b/InvenTree/plugin/registry.py
index d858d6c7f0..79892616c4 100644
--- a/InvenTree/plugin/registry.py
+++ b/InvenTree/plugin/registry.py
@@ -256,7 +256,7 @@ class Plugins:
                     self.mixins_globalsettings[slug] = plugin_setting
 
                     # Add to settings dir
-                    InvenTreeSetting.GLOBAL_SETTINGS.update(plugin_setting)
+                    InvenTreeSetting.SETTINGS.update(plugin_setting)
 
     def deactivate_integration_globalsettings(self):
         from common.models import InvenTreeSetting
@@ -268,7 +268,7 @@ class Plugins:
 
         # remove settings
         for setting in plugin_settings:
-            InvenTreeSetting.GLOBAL_SETTINGS.pop(setting)
+            InvenTreeSetting.SETTINGS.pop(setting)
 
         # clear cache
         self.mixins_globalsettings = {}
diff --git a/InvenTree/users/models.py b/InvenTree/users/models.py
index 5f2109da82..490f87f75a 100644
--- a/InvenTree/users/models.py
+++ b/InvenTree/users/models.py
@@ -76,7 +76,8 @@ class RuleSet(models.Model):
             'otp_totp_totpdevice',
             'otp_static_statictoken',
             'otp_static_staticdevice',
-            'plugin_pluginconfig'
+            'plugin_pluginconfig',
+            'plugin_pluginsetting',
         ],
         'part_category': [
             'part_partcategory',

From 737467a1fdd79c9f16b6ba00c30415e44dc8d0ac Mon Sep 17 00:00:00 2001
From: Oliver <oliver.henry.walters@gmail.com>
Date: Sat, 1 Jan 2022 23:14:34 +1100
Subject: [PATCH 4/9] Rename "has_globalsettings" -> "has_settings"

---
 InvenTree/plugin/builtin/integration/mixins.py |  6 +++---
 InvenTree/plugin/test_integration.py           | 12 +++---------
 2 files changed, 6 insertions(+), 12 deletions(-)

diff --git a/InvenTree/plugin/builtin/integration/mixins.py b/InvenTree/plugin/builtin/integration/mixins.py
index c7edc8ac36..c3437b7f8d 100644
--- a/InvenTree/plugin/builtin/integration/mixins.py
+++ b/InvenTree/plugin/builtin/integration/mixins.py
@@ -17,11 +17,11 @@ class SettingsMixin:
 
     def __init__(self):
         super().__init__()
-        self.add_mixin('settings', 'has_globalsettings', __class__)
+        self.add_mixin('settings', 'has_settings', __class__)
         self.globalsettings = getattr(self, 'SETTINGS', None)
 
     @property
-    def has_globalsettings(self):
+    def has_settings(self):
         """
         Does this plugin use custom global settings
         """
@@ -32,7 +32,7 @@ class SettingsMixin:
         """
         Get patterns for InvenTreeSetting defintion
         """
-        if self.has_globalsettings:
+        if self.has_settings:
             return {f'PLUGIN_{self.slug.upper()}_{key}': value for key, value in self.globalsettings.items()}
         return None
 
diff --git a/InvenTree/plugin/test_integration.py b/InvenTree/plugin/test_integration.py
index 1371535cfa..5cf00f7c86 100644
--- a/InvenTree/plugin/test_integration.py
+++ b/InvenTree/plugin/test_integration.py
@@ -23,7 +23,7 @@ class BaseMixinDefinition:
 class SettingsMixinTest(BaseMixinDefinition, TestCase):
     MIXIN_HUMAN_NAME = 'Settings'
     MIXIN_NAME = 'settings'
-    MIXIN_ENABLE_CHECK = 'has_globalsettings'
+    MIXIN_ENABLE_CHECK = 'has_settings'
 
     TEST_SETTINGS = {'SETTING1': {'default': '123', }}
 
@@ -44,21 +44,15 @@ class SettingsMixinTest(BaseMixinDefinition, TestCase):
         # settings variable
         self.assertEqual(self.mixin.globalsettings, self.TEST_SETTINGS)
 
-        # settings pattern
-        target_pattern = {f'PLUGIN_{self.mixin.slug.upper()}_{key}': value for key, value in self.mixin.globalsettings.items()}
-        self.assertEqual(self.mixin.globalsettingspatterns, target_pattern)
-
-        # no settings
-        self.assertIsNone(self.mixin_nothing.globalsettings)
-        self.assertIsNone(self.mixin_nothing.globalsettingspatterns)
-
         # calling settings
         # not existing
         self.assertEqual(self.mixin.get_globalsetting('ABCD'), '')
         self.assertEqual(self.mixin_nothing.get_globalsetting('ABCD'), '')
+
         # right setting
         self.mixin.set_globalsetting('SETTING1', '12345', self.test_user)
         self.assertEqual(self.mixin.get_globalsetting('SETTING1'), '12345')
+
         # no setting
         self.assertEqual(self.mixin_nothing.get_globalsetting(''), '')
 

From f3bfe6e7ca1c61e9971519540722d51fd81f60e6 Mon Sep 17 00:00:00 2001
From: Oliver <oliver.henry.walters@gmail.com>
Date: Sun, 2 Jan 2022 11:21:06 +1100
Subject: [PATCH 5/9] More refactoring:

- Rename "mixins_globalsettings" to "mixing_settings"
- Fix translation on settings page template
---
 InvenTree/plugin/admin.py                     | 12 +++--
 .../plugin/builtin/integration/mixins.py      | 53 +++++++++++--------
 InvenTree/plugin/integration.py               |  8 +--
 InvenTree/plugin/plugin.py                    | 24 ++++++++-
 InvenTree/plugin/registry.py                  | 10 ++--
 .../plugin/samples/integration/sample.py      |  9 ++++
 .../plugin/templatetags/plugin_extras.py      |  6 +--
 InvenTree/plugin/test_integration.py          | 10 ++--
 InvenTree/plugin/test_plugin.py               |  6 +--
 .../InvenTree/settings/mixins/settings.html   |  2 +-
 .../templates/InvenTree/settings/setting.html |  4 +-
 11 files changed, 91 insertions(+), 53 deletions(-)

diff --git a/InvenTree/plugin/admin.py b/InvenTree/plugin/admin.py
index 839dac305a..f13444f274 100644
--- a/InvenTree/plugin/admin.py
+++ b/InvenTree/plugin/admin.py
@@ -4,8 +4,7 @@ from __future__ import unicode_literals
 from django.contrib import admin
 
 import plugin.models as models
-from plugin import plugin_reg
-
+import plugin.registry as registry
 
 def plugin_update(queryset, new_status: bool):
     """general function for bulk changing plugins"""
@@ -20,7 +19,7 @@ def plugin_update(queryset, new_status: bool):
 
     # reload plugins if they changed
     if apps_changed:
-        plugin_reg.reload_plugins()
+        registry.plugin_registry.reload_plugins()
 
 
 @admin.action(description='Activate plugin(s)')
@@ -42,6 +41,13 @@ class PluginSettingInline(admin.TabularInline):
 
     model = models.PluginSetting
 
+    read_only_fields = [
+        'key',
+    ]
+
+    def has_add_permission(self, request, obj):
+        return False
+
 
 class PluginConfigAdmin(admin.ModelAdmin):
     """Custom admin with restricted id fields"""
diff --git a/InvenTree/plugin/builtin/integration/mixins.py b/InvenTree/plugin/builtin/integration/mixins.py
index c3437b7f8d..47e9ff630d 100644
--- a/InvenTree/plugin/builtin/integration/mixins.py
+++ b/InvenTree/plugin/builtin/integration/mixins.py
@@ -3,7 +3,9 @@ Plugin mixin classes
 """
 
 from django.conf.urls import url, include
+from django.db.utils import OperationalError, ProgrammingError
 
+from plugin.models import PluginConfig, PluginSetting
 from plugin.urls import PLUGIN_BASE
 
 
@@ -18,43 +20,48 @@ class SettingsMixin:
     def __init__(self):
         super().__init__()
         self.add_mixin('settings', 'has_settings', __class__)
-        self.globalsettings = getattr(self, 'SETTINGS', None)
+        self.settings = getattr(self, 'SETTINGS', {})
 
     @property
     def has_settings(self):
         """
         Does this plugin use custom global settings
         """
-        return bool(self.globalsettings)
+        return bool(self.settings)
 
-    @property
-    def globalsettingspatterns(self):
+    def get_setting(self, key):
         """
-        Get patterns for InvenTreeSetting defintion
+        Return the 'value' of the setting associated with this plugin
         """
-        if self.has_settings:
-            return {f'PLUGIN_{self.slug.upper()}_{key}': value for key, value in self.globalsettings.items()}
-        return None
 
-    def _globalsetting_name(self, key):
-        """
-        Get global name of setting
-        """
-        return f'PLUGIN_{self.slug.upper()}_{key}'
+        # Find the plugin configuration associated with this plugin
 
-    def get_globalsetting(self, key):
-        """
-        get plugin global setting by key
-        """
-        from common.models import InvenTreeSetting
-        return InvenTreeSetting.get_setting(self._globalsetting_name(key))
+        try:
+            plugin, _ = PluginConfig.objects.get_or_create(key=self.plugin_slug(), name=self.plugin_name())
+        except (OperationalError, ProgrammingError) as error:
+            plugin = None
+        
+        if not plugin:
+            # Plugin cannot be found, return default value
+            return PluginSetting.get_setting_default(key, settings=self.settings)
 
-    def set_globalsetting(self, key, value, user):
+        return PluginSetting.get_setting(key, plugin=plugin, settings=self.settings)
+
+    def set_setting(self, key, value, user):
         """
-        set plugin global setting by key
+        Set plugin setting value by key
         """
-        from common.models import InvenTreeSetting
-        return InvenTreeSetting.set_setting(self._globalsetting_name(key), value, user)
+
+        try:
+            plugin, _ = PluginConfig.objects.get_or_create(key=self.plugin_slug(), name=self.plugin_name())
+        except (OperationalError, ProgrammingError) as error:
+            plugin = None
+
+        if not plugin:
+            # Cannot find associated plugin model, return
+            return        
+
+        PluginSetting.set_setting(key, value, user, plugin=plugin, settings=self.settings)
 
 
 class UrlsMixin:
diff --git a/InvenTree/plugin/integration.py b/InvenTree/plugin/integration.py
index 3cd8ae86d2..d66ab79c26 100644
--- a/InvenTree/plugin/integration.py
+++ b/InvenTree/plugin/integration.py
@@ -59,8 +59,6 @@ class IntegrationPluginBase(MixinBase, plugin.InvenTreePlugin):
     """
     The IntegrationPluginBase class is used to integrate with 3rd party software
     """
-    PLUGIN_SLUG = None
-    PLUGIN_TITLE = None
 
     AUTHOR = None
     DESCRIPTION = None
@@ -84,11 +82,7 @@ class IntegrationPluginBase(MixinBase, plugin.InvenTreePlugin):
     # region properties
     @property
     def slug(self):
-        """slug for the plugin"""
-        slug = getattr(self, 'PLUGIN_SLUG', None)
-        if not slug:
-            slug = self.plugin_name()
-        return slugify(slug)
+        return self.plugin_slug()
 
     @property
     def human_name(self):
diff --git a/InvenTree/plugin/plugin.py b/InvenTree/plugin/plugin.py
index 93199df7b7..dbafda0aea 100644
--- a/InvenTree/plugin/plugin.py
+++ b/InvenTree/plugin/plugin.py
@@ -2,6 +2,9 @@
 """Base Class for InvenTree plugins"""
 
 
+from django.utils.text import slugify
+
+
 class InvenTreePlugin():
     """
     Base class for a plugin
@@ -10,9 +13,28 @@ class InvenTreePlugin():
     # Override the plugin name for each concrete plugin instance
     PLUGIN_NAME = ''
 
+    PLUGIN_SLUG = ''
+
+    PLUGIN_TITLE = ''
+
     def plugin_name(self):
-        """get plugin name"""
+        """
+        Return the name of this plugin plugin
+        """
         return self.PLUGIN_NAME
 
+    def plugin_slug(self):
+
+        slug = getattr(self, 'PLUGIN_SLUG', None)
+
+        if slug is None:
+            slug = self.plugin_name()
+
+        return slugify(slug)
+
+    def plugin_title(self):
+
+        return self.PLUGIN_TITLE
+
     def __init__(self):
         pass
diff --git a/InvenTree/plugin/registry.py b/InvenTree/plugin/registry.py
index 79892616c4..e0b25ecc65 100644
--- a/InvenTree/plugin/registry.py
+++ b/InvenTree/plugin/registry.py
@@ -50,7 +50,7 @@ class Plugins:
         # integration specific
         self.installed_apps = []         # Holds all added plugin_paths
         # mixins
-        self.mixins_globalsettings = {}
+        self.mixins_settings = {}
 
     # region public plugin functions
     def load_plugins(self):
@@ -252,8 +252,8 @@ class Plugins:
             logger.info('Registering IntegrationPlugin global settings')
             for slug, plugin in plugins:
                 if plugin.mixin_enabled('settings'):
-                    plugin_setting = plugin.globalsettingspatterns
-                    self.mixins_globalsettings[slug] = plugin_setting
+                    plugin_setting = plugin.settings
+                    self.mixins_settings[slug] = plugin_setting
 
                     # Add to settings dir
                     InvenTreeSetting.SETTINGS.update(plugin_setting)
@@ -263,7 +263,7 @@ class Plugins:
 
         # collect all settings
         plugin_settings = {}
-        for _, plugin_setting in self.mixins_globalsettings.items():
+        for _, plugin_setting in self.mixins_settings.items():
             plugin_settings.update(plugin_setting)
 
         # remove settings
@@ -271,7 +271,7 @@ class Plugins:
             InvenTreeSetting.SETTINGS.pop(setting)
 
         # clear cache
-        self.mixins_globalsettings = {}
+        self.mixins_Fsettings = {}
     # endregion
 
     # region integration_app
diff --git a/InvenTree/plugin/samples/integration/sample.py b/InvenTree/plugin/samples/integration/sample.py
index afc4a8fe8a..682726718f 100644
--- a/InvenTree/plugin/samples/integration/sample.py
+++ b/InvenTree/plugin/samples/integration/sample.py
@@ -44,6 +44,15 @@ class SampleIntegrationPlugin(AppMixin, SettingsMixin, UrlsMixin, NavigationMixi
             'default': True,
             'validator': bool,
         },
+        'API_KEY': {
+            'name': _('API Key'),
+            'description': _('Key required for accessing external API'),
+        },
+        'NUMERICAL_SETTING': {
+            'name': _('Numerical'),
+            'description': _('A numerical setting'),
+            'validator': int,
+        },
     }
 
     NAVIGATION = [
diff --git a/InvenTree/plugin/templatetags/plugin_extras.py b/InvenTree/plugin/templatetags/plugin_extras.py
index 1b4b269844..b7f4f6b6c1 100644
--- a/InvenTree/plugin/templatetags/plugin_extras.py
+++ b/InvenTree/plugin/templatetags/plugin_extras.py
@@ -26,9 +26,9 @@ def inactive_plugin_list(*args, **kwargs):
 
 
 @register.simple_tag()
-def plugin_globalsettings(plugin, *args, **kwargs):
-    """ Return a list of all global settings for a plugin """
-    return plugin_reg.mixins_globalsettings.get(plugin)
+def plugin_settings(plugin, *args, **kwargs):
+    """ Return a list of all custom settings for a plugin """
+    return plugin_reg.mixins_settings.get(plugin)
 
 
 @register.simple_tag()
diff --git a/InvenTree/plugin/test_integration.py b/InvenTree/plugin/test_integration.py
index 5cf00f7c86..f88994384a 100644
--- a/InvenTree/plugin/test_integration.py
+++ b/InvenTree/plugin/test_integration.py
@@ -46,15 +46,15 @@ class SettingsMixinTest(BaseMixinDefinition, TestCase):
 
         # calling settings
         # not existing
-        self.assertEqual(self.mixin.get_globalsetting('ABCD'), '')
-        self.assertEqual(self.mixin_nothing.get_globalsetting('ABCD'), '')
+        self.assertEqual(self.mixin.get_setting('ABCD'), '')
+        self.assertEqual(self.mixin_nothing.get_setting('ABCD'), '')
 
         # right setting
-        self.mixin.set_globalsetting('SETTING1', '12345', self.test_user)
-        self.assertEqual(self.mixin.get_globalsetting('SETTING1'), '12345')
+        self.mixin.set_setting('SETTING1', '12345', self.test_user)
+        self.assertEqual(self.mixin.get_setting('SETTING1'), '12345')
 
         # no setting
-        self.assertEqual(self.mixin_nothing.get_globalsetting(''), '')
+        self.assertEqual(self.mixin_nothing.get_setting(''), '')
 
 
 class UrlsMixinTest(BaseMixinDefinition, TestCase):
diff --git a/InvenTree/plugin/test_plugin.py b/InvenTree/plugin/test_plugin.py
index 3e0a1967db..b4e2fdaf6f 100644
--- a/InvenTree/plugin/test_plugin.py
+++ b/InvenTree/plugin/test_plugin.py
@@ -63,11 +63,11 @@ class PluginTagTests(TestCase):
         """test that all inactive plugins are listed"""
         self.assertEqual(plugin_tags.inactive_plugin_list(), plugin_reg.plugins_inactive)
 
-    def test_tag_plugin_globalsettings(self):
+    def test_tag_plugin_settings(self):
         """check all plugins are listed"""
         self.assertEqual(
-            plugin_tags.plugin_globalsettings(self.sample),
-            plugin_reg.mixins_globalsettings.get(self.sample)
+            plugin_tags.plugin_settings(self.sample),
+            plugin_reg.mixins_settings.get(self.sample)
         )
 
     def test_tag_mixin_enabled(self):
diff --git a/InvenTree/templates/InvenTree/settings/mixins/settings.html b/InvenTree/templates/InvenTree/settings/mixins/settings.html
index 910d4bbfbb..cb136b5c4f 100644
--- a/InvenTree/templates/InvenTree/settings/mixins/settings.html
+++ b/InvenTree/templates/InvenTree/settings/mixins/settings.html
@@ -5,7 +5,7 @@
     <h4>{% trans "Settings" %}</h4>
 </div>
 
-{% plugin_globalsettings plugin_key as plugin_settings %}
+{% plugin_settings plugin_key as plugin_settings %}
 
 <table class='table table-striped table-condensed'>
     <tbody>
diff --git a/InvenTree/templates/InvenTree/settings/setting.html b/InvenTree/templates/InvenTree/settings/setting.html
index 7419b7ff34..96a8993798 100644
--- a/InvenTree/templates/InvenTree/settings/setting.html
+++ b/InvenTree/templates/InvenTree/settings/setting.html
@@ -13,7 +13,7 @@
         <span class='fas {{ icon }}'></span>
         {% endif %}
     </td>
-    <td><strong>{% trans setting.name %}</strong></td>
+    <td><strong>{{ setting.name }}</strong></td>
     <td>
         {% if setting.is_bool %}
         <div class='form-check form-switch'>
@@ -32,7 +32,7 @@
         </div>
         {% endif %}
     <td>
-        {% trans setting.description %}
+        {{ setting.description }}
     </td>
     <td>
         <div class='btn-group float-right'>

From dc9e25ebad12ee9fe23903d4bd8ac06df033d3b1 Mon Sep 17 00:00:00 2001
From: Oliver <oliver.henry.walters@gmail.com>
Date: Sun, 2 Jan 2022 14:12:34 +1100
Subject: [PATCH 6/9] Adds API endpoints for viewing and updating plugin
 settings

A lot of code updates / refactoring here to get this to work as expected
---
 InvenTree/InvenTree/version.py                |  6 +-
 InvenTree/common/models.py                    | 26 ++++----
 .../part/templatetags/inventree_extras.py     | 13 +++-
 InvenTree/plugin/__init__.py                  |  8 ++-
 InvenTree/plugin/admin.py                     |  1 +
 InvenTree/plugin/api.py                       | 42 ++++++++++++-
 InvenTree/plugin/apps.py                      |  8 +--
 .../plugin/builtin/integration/mixins.py      | 17 +++--
 InvenTree/plugin/helpers.py                   |  8 +--
 InvenTree/plugin/integration.py               |  1 -
 InvenTree/plugin/loader.py                    |  4 +-
 InvenTree/plugin/models.py                    | 62 +++++++++++++++++--
 InvenTree/plugin/plugin.py                    | 30 +++++++--
 InvenTree/plugin/registry.py                  | 23 +++----
 .../plugin/samples/integration/sample.py      | 12 ++++
 InvenTree/plugin/serializers.py               | 25 +++++++-
 .../plugin/templatetags/plugin_extras.py      | 10 +--
 InvenTree/plugin/test_api.py                  |  4 +-
 InvenTree/plugin/test_plugin.py               | 10 +--
 InvenTree/plugin/urls.py                      |  4 +-
 .../InvenTree/settings/mixins/settings.html   |  2 +-
 .../templates/InvenTree/settings/setting.html |  8 ++-
 InvenTree/templates/js/dynamic/settings.js    |  6 +-
 23 files changed, 250 insertions(+), 80 deletions(-)

diff --git a/InvenTree/InvenTree/version.py b/InvenTree/InvenTree/version.py
index 79bc44bc0e..1f8e372d39 100644
--- a/InvenTree/InvenTree/version.py
+++ b/InvenTree/InvenTree/version.py
@@ -12,11 +12,15 @@ import common.models
 INVENTREE_SW_VERSION = "0.6.0 dev"
 
 # InvenTree API version
-INVENTREE_API_VERSION = 22
+INVENTREE_API_VERSION = 23
 
 """
 Increment this API version number whenever there is a significant change to the API that any clients need to know about
 
+v23 -> 2022-02-02
+    - Adds API endpoints for managing plugin classes
+    - Adds API endpoints for managing plugin settings
+
 v22 -> 2021-12-20
     - Adds API endpoint to "merge" multiple stock items
 
diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py
index ecddd17de8..048a968d1a 100644
--- a/InvenTree/common/models.py
+++ b/InvenTree/common/models.py
@@ -71,7 +71,7 @@ class BaseInvenTreeSetting(models.Model):
         super().save()
 
     @classmethod
-    def allValues(cls, user=None, plugin=None, exclude_hidden=False):
+    def allValues(cls, user=None, exclude_hidden=False):
         """
         Return a dict of "all" defined global settings.
 
@@ -86,10 +86,6 @@ class BaseInvenTreeSetting(models.Model):
         if user is not None:
             results = results.filter(user=user)
 
-        # Optionally filter by plugin
-        if plugin is not None:
-            results = results.filter(plugin=plugin)
-
         # Query the database
         settings = {}
 
@@ -238,16 +234,12 @@ class BaseInvenTreeSetting(models.Model):
 
         settings = cls.objects.all()
 
+        # Filter by user
         user = kwargs.get('user', None)
 
         if user is not None:
             settings = settings.filter(user=user)
 
-        plugin = kwargs.get('plugin', None)
-
-        if plugin is not None:
-            settings = settings.filter(plugin=plugin)
-
         try:
             setting = settings.filter(**cls.get_filters(key, **kwargs)).first()
         except (ValueError, cls.DoesNotExist):
@@ -255,6 +247,16 @@ class BaseInvenTreeSetting(models.Model):
         except (IntegrityError, OperationalError):
             setting = None
 
+        plugin = kwargs.pop('plugin', None)
+
+        if plugin:
+            from plugin import InvenTreePlugin
+
+            if issubclass(plugin.__class__, InvenTreePlugin):
+                plugin = plugin.plugin_config()
+
+            kwargs['plugin'] = plugin
+
         # Setting does not exist! (Try to create it)
         if not setting:
 
@@ -554,7 +556,9 @@ class BaseInvenTreeSetting(models.Model):
 
 
 def settings_group_options():
-    """build up group tuple for settings based on gour choices"""
+    """
+    Build up group tuple for settings based on your choices
+    """
     return [('', _('No group')), *[(str(a.id), str(a)) for a in Group.objects.all()]]
 
 
diff --git a/InvenTree/part/templatetags/inventree_extras.py b/InvenTree/part/templatetags/inventree_extras.py
index 9dc09535fa..9c0b0e691f 100644
--- a/InvenTree/part/templatetags/inventree_extras.py
+++ b/InvenTree/part/templatetags/inventree_extras.py
@@ -1,6 +1,7 @@
 # -*- coding: utf-8 -*-
 
-""" This module provides template tags for extra functionality
+"""
+This module provides template tags for extra functionality,
 over and above the built-in Django tags.
 """
 
@@ -22,6 +23,8 @@ import InvenTree.helpers
 from common.models import InvenTreeSetting, ColorTheme, InvenTreeUserSetting
 from common.settings import currency_code_default
 
+from plugin.models import PluginSetting
+
 register = template.Library()
 
 
@@ -223,8 +226,16 @@ def setting_object(key, *args, **kwargs):
     if a user-setting was requested return that
     """
 
+    if 'plugin' in kwargs:
+        # Note, 'plugin' is an instance of an InvenTreePlugin class
+
+        plugin = kwargs['plugin']
+
+        return PluginSetting.get_setting_object(key, plugin=plugin)
+
     if 'user' in kwargs:
         return InvenTreeUserSetting.get_setting_object(key, user=kwargs['user'])
+
     return InvenTreeSetting.get_setting_object(key)
 
 
diff --git a/InvenTree/plugin/__init__.py b/InvenTree/plugin/__init__.py
index 973d341171..b3dc3a2fd0 100644
--- a/InvenTree/plugin/__init__.py
+++ b/InvenTree/plugin/__init__.py
@@ -1,7 +1,11 @@
-from .registry import plugins as plugin_reg
+from .registry import plugin_registry
+from .plugin import InvenTreePlugin
 from .integration import IntegrationPluginBase
 from .action import ActionPlugin
 
 __all__ = [
-    'plugin_reg', 'IntegrationPluginBase', 'ActionPlugin',
+    'ActionPlugin',
+    'IntegrationPluginBase',
+    'InvenTreePlugin',
+    'plugin_registry',
 ]
diff --git a/InvenTree/plugin/admin.py b/InvenTree/plugin/admin.py
index f13444f274..e27c499479 100644
--- a/InvenTree/plugin/admin.py
+++ b/InvenTree/plugin/admin.py
@@ -6,6 +6,7 @@ from django.contrib import admin
 import plugin.models as models
 import plugin.registry as registry
 
+
 def plugin_update(queryset, new_status: bool):
     """general function for bulk changing plugins"""
     apps_changed = False
diff --git a/InvenTree/plugin/api.py b/InvenTree/plugin/api.py
index 4aecd6bb24..9ab3b96724 100644
--- a/InvenTree/plugin/api.py
+++ b/InvenTree/plugin/api.py
@@ -11,7 +11,8 @@ from rest_framework import generics
 from rest_framework import status
 from rest_framework.response import Response
 
-from plugin.models import PluginConfig
+from common.api import GlobalSettingsPermissions
+from plugin.models import PluginConfig, PluginSetting
 import plugin.serializers as PluginSerializers
 
 
@@ -76,7 +77,46 @@ class PluginInstall(generics.CreateAPIView):
         return serializer.save()
 
 
+class PluginSettingList(generics.ListAPIView):
+    """
+    List endpoint for all plugin related settings.
+
+    - read only
+    - only accessible by staff users
+    """
+
+    queryset = PluginSetting.objects.all()
+    serializer_class = PluginSerializers.PluginSettingSerializer
+
+    permission_classes = [
+        GlobalSettingsPermissions,
+    ]
+
+
+class PluginSettingDetail(generics.RetrieveUpdateAPIView):
+    """
+    Detail endpoint for a plugin-specific setting.
+
+    Note that these cannot be created or deleted via the API
+    """
+
+    queryset = PluginSetting.objects.all()
+    serializer_class = PluginSerializers.PluginSettingSerializer
+
+    # Staff permission required
+    permission_classes = [
+        GlobalSettingsPermissions,
+    ]
+
+
 plugin_api_urls = [
+
+    # Plugin settings URLs
+    url(r'^settings/', include([
+        url(r'^(?P<pk>\d+)/', PluginSettingDetail.as_view(), name='api-plugin-setting-detail'),
+        url(r'^.*$', PluginSettingList.as_view(), name='api-plugin-setting-list'),
+    ])),
+
     # Detail views for a single PluginConfig item
     url(r'^(?P<pk>\d+)/', include([
         url(r'^.*$', PluginDetail.as_view(), name='api-plugin-detail'),
diff --git a/InvenTree/plugin/apps.py b/InvenTree/plugin/apps.py
index 8a3cd97889..cca9dee91c 100644
--- a/InvenTree/plugin/apps.py
+++ b/InvenTree/plugin/apps.py
@@ -4,17 +4,17 @@ from __future__ import unicode_literals
 from django.apps import AppConfig
 from maintenance_mode.core import set_maintenance_mode
 
-from plugin.registry import plugins
+from plugin import plugin_registry
 
 
 class PluginAppConfig(AppConfig):
     name = 'plugin'
 
     def ready(self):
-        if not plugins.is_loading:
+        if not plugin_registry.is_loading:
             # this is the first startup
-            plugins.collect_plugins()
-            plugins.load_plugins()
+            plugin_registry.collect_plugins()
+            plugin_registry.load_plugins()
 
             # drop out of maintenance
             # makes sure we did not have an error in reloading and maintenance is still active
diff --git a/InvenTree/plugin/builtin/integration/mixins.py b/InvenTree/plugin/builtin/integration/mixins.py
index 47e9ff630d..30f3cd8551 100644
--- a/InvenTree/plugin/builtin/integration/mixins.py
+++ b/InvenTree/plugin/builtin/integration/mixins.py
@@ -36,17 +36,14 @@ class SettingsMixin:
 
         # Find the plugin configuration associated with this plugin
 
-        try:
-            plugin, _ = PluginConfig.objects.get_or_create(key=self.plugin_slug(), name=self.plugin_name())
-        except (OperationalError, ProgrammingError) as error:
-            plugin = None
-        
-        if not plugin:
+        plugin = self.plugin_config()
+
+        if plugin:
+            return PluginSetting.get_setting(key, plugin=plugin, settings=self.settings)
+        else:
             # Plugin cannot be found, return default value
             return PluginSetting.get_setting_default(key, settings=self.settings)
 
-        return PluginSetting.get_setting(key, plugin=plugin, settings=self.settings)
-
     def set_setting(self, key, value, user):
         """
         Set plugin setting value by key
@@ -54,12 +51,12 @@ class SettingsMixin:
 
         try:
             plugin, _ = PluginConfig.objects.get_or_create(key=self.plugin_slug(), name=self.plugin_name())
-        except (OperationalError, ProgrammingError) as error:
+        except (OperationalError, ProgrammingError):
             plugin = None
 
         if not plugin:
             # Cannot find associated plugin model, return
-            return        
+            return
 
         PluginSetting.set_setting(key, value, user, plugin=plugin, settings=self.settings)
 
diff --git a/InvenTree/plugin/helpers.py b/InvenTree/plugin/helpers.py
index 003ca707b4..fb46df8927 100644
--- a/InvenTree/plugin/helpers.py
+++ b/InvenTree/plugin/helpers.py
@@ -10,14 +10,14 @@ from django.conf import settings
 
 # region logging / errors
 def log_plugin_error(error, reference: str = 'general'):
-    from plugin import plugin_reg
+    from plugin import plugin_registry
 
     # make sure the registry is set up
-    if reference not in plugin_reg.errors:
-        plugin_reg.errors[reference] = []
+    if reference not in plugin_registry.errors:
+        plugin_registry.errors[reference] = []
 
     # add error to stack
-    plugin_reg.errors[reference].append(error)
+    plugin_registry.errors[reference].append(error)
 
 
 class IntegrationPluginError(Exception):
diff --git a/InvenTree/plugin/integration.py b/InvenTree/plugin/integration.py
index d66ab79c26..2c593291d3 100644
--- a/InvenTree/plugin/integration.py
+++ b/InvenTree/plugin/integration.py
@@ -9,7 +9,6 @@ import pathlib
 
 from django.urls.base import reverse
 from django.conf import settings
-from django.utils.text import slugify
 from django.utils.translation import ugettext_lazy as _
 
 import plugin.plugin as plugin
diff --git a/InvenTree/plugin/loader.py b/InvenTree/plugin/loader.py
index 2491336a51..2d17f8c36f 100644
--- a/InvenTree/plugin/loader.py
+++ b/InvenTree/plugin/loader.py
@@ -4,7 +4,7 @@ load templates for loaded plugins
 from django.template.loaders.filesystem import Loader as FilesystemLoader
 from pathlib import Path
 
-from plugin import plugin_reg
+from plugin import plugin_registry
 
 
 class PluginTemplateLoader(FilesystemLoader):
@@ -12,7 +12,7 @@ class PluginTemplateLoader(FilesystemLoader):
     def get_dirs(self):
         dirname = 'templates'
         template_dirs = []
-        for plugin in plugin_reg.plugins.values():
+        for plugin in plugin_registry.plugins.values():
             new_path = Path(plugin.path) / dirname
             if Path(new_path).is_dir():
                 template_dirs.append(new_path)
diff --git a/InvenTree/plugin/models.py b/InvenTree/plugin/models.py
index b001798544..f477df5c27 100644
--- a/InvenTree/plugin/models.py
+++ b/InvenTree/plugin/models.py
@@ -10,7 +10,7 @@ from django.db import models
 
 import common.models
 
-from plugin import plugin_reg
+from plugin import InvenTreePlugin, plugin_registry
 
 
 class PluginConfig(models.Model):
@@ -72,7 +72,7 @@ class PluginConfig(models.Model):
         self.__org_active = self.active
 
         # append settings from registry
-        self.plugin = plugin_reg.plugins.get(self.key, None)
+        self.plugin = plugin_registry.plugins.get(self.key, None)
 
         def get_plugin_meta(name):
             if self.plugin:
@@ -95,10 +95,10 @@ class PluginConfig(models.Model):
 
         if not reload:
             if self.active is False and self.__org_active is True:
-                plugin_reg.reload_plugins()
+                plugin_registry.reload_plugins()
 
             elif self.active is True and self.__org_active is False:
-                plugin_reg.reload_plugins()
+                plugin_registry.reload_plugins()
 
         return ret
 
@@ -113,6 +113,58 @@ class PluginSetting(common.models.BaseInvenTreeSetting):
             ('plugin', 'key'),
         ]
 
+    """
+    We override the following class methods,
+    so that we can pass the plugin instance
+    """
+
+    @property
+    def name(self):
+        return self.__class__.get_setting_name(self.key, plugin=self.plugin)
+
+    @property
+    def default_value(self):
+        return self.__class__.get_setting_default(self.key, plugin=self.plugin)
+
+    @property
+    def description(self):
+        return self.__class__.get_setting_description(self.key, plugin=self.plugin)
+
+    @property
+    def units(self):
+        return self.__class__.get_setting_units(self.key, plugin=self.plugin)
+
+    def choices(self):
+        return self.__class__.get_setting_choices(self.key, plugin=self.plugin)
+
+    @classmethod
+    def get_setting_definition(cls, key, **kwargs):
+        """
+        In the BaseInvenTreeSetting class, we have a class attribute named 'SETTINGS',
+        which is a dict object that fully defines all the setting parameters.
+
+        Here, unlike the BaseInvenTreeSetting, we do not know the definitions of all settings
+        'ahead of time' (as they are defined externally in the plugins).
+
+        Settings can be provided by the caller, as kwargs['settings'].
+
+        If not provided, we'll look at the plugin registry to see what settings are available,
+        (if the plugin is specified!)
+        """
+
+        if 'settings' not in kwargs:
+
+            plugin = kwargs.pop('plugin', None)
+
+            if plugin:
+
+                if issubclass(plugin.__class__, InvenTreePlugin):
+                    plugin = plugin.plugin_config()
+
+                kwargs['settings'] = plugin_registry.mixins_settings.get(plugin.key, {})
+
+        return super().get_setting_definition(key, **kwargs)
+
     @classmethod
     def get_filters(cls, key, **kwargs):
         """
@@ -124,6 +176,8 @@ class PluginSetting(common.models.BaseInvenTreeSetting):
         plugin = kwargs.get('plugin', None)
 
         if plugin:
+            if issubclass(plugin.__class__, InvenTreePlugin):
+                plugin = plugin.plugin_config()
             filters['plugin'] = plugin
 
         return filters
diff --git a/InvenTree/plugin/plugin.py b/InvenTree/plugin/plugin.py
index dbafda0aea..0cf8082b7f 100644
--- a/InvenTree/plugin/plugin.py
+++ b/InvenTree/plugin/plugin.py
@@ -1,7 +1,9 @@
 # -*- coding: utf-8 -*-
-"""Base Class for InvenTree plugins"""
-
+"""
+Base Class for InvenTree plugins
+"""
 
+from django.db.utils import OperationalError, ProgrammingError
 from django.utils.text import slugify
 
 
@@ -10,6 +12,9 @@ class InvenTreePlugin():
     Base class for a plugin
     """
 
+    def __init__(self):
+        pass
+
     # Override the plugin name for each concrete plugin instance
     PLUGIN_NAME = ''
 
@@ -36,5 +41,22 @@ class InvenTreePlugin():
 
         return self.PLUGIN_TITLE
 
-    def __init__(self):
-        pass
+    def plugin_config(self, raise_error=False):
+        """
+        Return the PluginConfig object associated with this plugin
+        """
+
+        try:
+            import plugin.models
+
+            cfg, _ = plugin.models.PluginConfig.objects.get_or_create(
+                key=self.plugin_slug(),
+                name=self.plugin_name(),
+            )
+        except (OperationalError, ProgrammingError) as error:
+            cfg = None
+
+            if raise_error:
+                raise error
+
+        return cfg
diff --git a/InvenTree/plugin/registry.py b/InvenTree/plugin/registry.py
index e0b25ecc65..30dbad5f95 100644
--- a/InvenTree/plugin/registry.py
+++ b/InvenTree/plugin/registry.py
@@ -33,7 +33,7 @@ from .helpers import get_plugin_error, IntegrationPluginError
 logger = logging.getLogger('inventree')
 
 
-class Plugins:
+class PluginsRegistry:
     def __init__(self) -> None:
         # plugin registry
         self.plugins = {}
@@ -225,7 +225,8 @@ class Plugins:
                 self.plugins_inactive[plug_key] = plugin_db_setting
 
     def _activate_plugins(self, force_reload=False):
-        """run integration functions for all plugins
+        """
+        Run integration functions for all plugins
 
         :param force_reload: force reload base apps, defaults to False
         :type force_reload: bool, optional
@@ -238,13 +239,13 @@ class Plugins:
         self.activate_integration_app(plugins, force_reload=force_reload)
 
     def _deactivate_plugins(self):
-        """run integration deactivation functions for all plugins"""
+        """
+        Run integration deactivation functions for all plugins
+        """
         self.deactivate_integration_app()
         self.deactivate_integration_globalsettings()
     # endregion
 
-    # region specific integrations
-    # region integration_globalsettings
     def activate_integration_globalsettings(self, plugins):
         from common.models import InvenTreeSetting
 
@@ -255,24 +256,16 @@ class Plugins:
                     plugin_setting = plugin.settings
                     self.mixins_settings[slug] = plugin_setting
 
-                    # Add to settings dir
-                    InvenTreeSetting.SETTINGS.update(plugin_setting)
-
     def deactivate_integration_globalsettings(self):
-        from common.models import InvenTreeSetting
 
         # collect all settings
         plugin_settings = {}
+
         for _, plugin_setting in self.mixins_settings.items():
             plugin_settings.update(plugin_setting)
 
-        # remove settings
-        for setting in plugin_settings:
-            InvenTreeSetting.SETTINGS.pop(setting)
-
         # clear cache
         self.mixins_Fsettings = {}
-    # endregion
 
     # region integration_app
     def activate_integration_app(self, plugins, force_reload=False):
@@ -452,4 +445,4 @@ class Plugins:
     # endregion
 
 
-plugins = Plugins()
+plugin_registry = PluginsRegistry()
diff --git a/InvenTree/plugin/samples/integration/sample.py b/InvenTree/plugin/samples/integration/sample.py
index 682726718f..a05e804def 100644
--- a/InvenTree/plugin/samples/integration/sample.py
+++ b/InvenTree/plugin/samples/integration/sample.py
@@ -52,6 +52,18 @@ class SampleIntegrationPlugin(AppMixin, SettingsMixin, UrlsMixin, NavigationMixi
             'name': _('Numerical'),
             'description': _('A numerical setting'),
             'validator': int,
+            'default': 123,
+        },
+        'CHOICE_SETTING': {
+            'name': _("Choice Setting"),
+            'description': _('A setting with multiple choices'),
+            'choices': [
+                ('A', 'Anaconda'),
+                ('B', 'Bat'),
+                ('C', 'Cat'),
+                ('D', 'Dog'),
+            ],
+            'default': 'A',
         },
     }
 
diff --git a/InvenTree/plugin/serializers.py b/InvenTree/plugin/serializers.py
index e25e253498..da171a604d 100644
--- a/InvenTree/plugin/serializers.py
+++ b/InvenTree/plugin/serializers.py
@@ -14,7 +14,9 @@ from django.utils.translation import ugettext_lazy as _
 
 from rest_framework import serializers
 
-from plugin.models import PluginConfig
+from common.serializers import SettingsSerializer
+
+from plugin.models import PluginConfig, PluginSetting
 
 
 class PluginConfigSerializer(serializers.ModelSerializer):
@@ -117,3 +119,24 @@ class PluginConfigInstallSerializer(serializers.Serializer):
         # TODO
 
         return ret
+
+
+class PluginSettingSerializer(SettingsSerializer):
+    """
+    Serializer for the PluginSetting model
+    """
+
+    plugin = serializers.PrimaryKeyRelatedField(read_only=True)
+
+    class Meta:
+        model = PluginSetting
+        fields = [
+            'pk',
+            'key',
+            'value',
+            'name',
+            'description',
+            'type',
+            'choices',
+            'plugin',
+        ]
diff --git a/InvenTree/plugin/templatetags/plugin_extras.py b/InvenTree/plugin/templatetags/plugin_extras.py
index b7f4f6b6c1..7852133ebb 100644
--- a/InvenTree/plugin/templatetags/plugin_extras.py
+++ b/InvenTree/plugin/templatetags/plugin_extras.py
@@ -7,7 +7,7 @@ from django import template
 from django.urls import reverse
 
 from common.models import InvenTreeSetting
-from plugin import plugin_reg
+from plugin import plugin_registry
 
 
 register = template.Library()
@@ -16,19 +16,19 @@ register = template.Library()
 @register.simple_tag()
 def plugin_list(*args, **kwargs):
     """ Return a list of all installed integration plugins """
-    return plugin_reg.plugins
+    return plugin_registry.plugins
 
 
 @register.simple_tag()
 def inactive_plugin_list(*args, **kwargs):
     """ Return a list of all inactive integration plugins """
-    return plugin_reg.plugins_inactive
+    return plugin_registry.plugins_inactive
 
 
 @register.simple_tag()
 def plugin_settings(plugin, *args, **kwargs):
     """ Return a list of all custom settings for a plugin """
-    return plugin_reg.mixins_settings.get(plugin)
+    return plugin_registry.mixins_settings.get(plugin)
 
 
 @register.simple_tag()
@@ -57,4 +57,4 @@ def safe_url(view_name, *args, **kwargs):
 @register.simple_tag()
 def plugin_errors(*args, **kwargs):
     """Return all plugin errors"""
-    return plugin_reg.errors
+    return plugin_registry.errors
diff --git a/InvenTree/plugin/test_api.py b/InvenTree/plugin/test_api.py
index 0bb5f7789b..fe1e9802bf 100644
--- a/InvenTree/plugin/test_api.py
+++ b/InvenTree/plugin/test_api.py
@@ -64,14 +64,14 @@ class PluginDetailAPITest(InvenTreeAPITestCase):
         Test the PluginConfig action commands
         """
         from plugin.models import PluginConfig
-        from plugin import plugin_reg
+        from plugin import plugin_registry
 
         url = reverse('admin:plugin_pluginconfig_changelist')
         fixtures = PluginConfig.objects.all()
 
         # check if plugins were registered -> in some test setups the startup has no db access
         if not fixtures:
-            plugin_reg.reload_plugins()
+            plugin_registry.reload_plugins()
             fixtures = PluginConfig.objects.all()
 
         print([str(a) for a in fixtures])
diff --git a/InvenTree/plugin/test_plugin.py b/InvenTree/plugin/test_plugin.py
index b4e2fdaf6f..5724bdb0a9 100644
--- a/InvenTree/plugin/test_plugin.py
+++ b/InvenTree/plugin/test_plugin.py
@@ -8,7 +8,7 @@ from plugin.samples.integration.sample import SampleIntegrationPlugin
 from plugin.samples.integration.another_sample import WrongIntegrationPlugin, NoIntegrationPlugin
 # from plugin.plugins import load_action_plugins, load_barcode_plugins
 import plugin.templatetags.plugin_extras as plugin_tags
-from plugin import plugin_reg
+from plugin import plugin_registry
 
 
 class InvenTreePluginTests(TestCase):
@@ -57,17 +57,17 @@ class PluginTagTests(TestCase):
 
     def test_tag_plugin_list(self):
         """test that all plugins are listed"""
-        self.assertEqual(plugin_tags.plugin_list(), plugin_reg.plugins)
+        self.assertEqual(plugin_tags.plugin_list(), plugin_registry.plugins)
 
     def test_tag_incative_plugin_list(self):
         """test that all inactive plugins are listed"""
-        self.assertEqual(plugin_tags.inactive_plugin_list(), plugin_reg.plugins_inactive)
+        self.assertEqual(plugin_tags.inactive_plugin_list(), plugin_registry.plugins_inactive)
 
     def test_tag_plugin_settings(self):
         """check all plugins are listed"""
         self.assertEqual(
             plugin_tags.plugin_settings(self.sample),
-            plugin_reg.mixins_settings.get(self.sample)
+            plugin_registry.mixins_settings.get(self.sample)
         )
 
     def test_tag_mixin_enabled(self):
@@ -89,4 +89,4 @@ class PluginTagTests(TestCase):
 
     def test_tag_plugin_errors(self):
         """test that all errors are listed"""
-        self.assertEqual(plugin_tags.plugin_errors(), plugin_reg.errors)
+        self.assertEqual(plugin_tags.plugin_errors(), plugin_registry.errors)
diff --git a/InvenTree/plugin/urls.py b/InvenTree/plugin/urls.py
index 419dce5a88..46cdfd8a84 100644
--- a/InvenTree/plugin/urls.py
+++ b/InvenTree/plugin/urls.py
@@ -3,7 +3,7 @@ URL lookup for plugin app
 """
 from django.conf.urls import url, include
 
-from plugin import plugin_reg
+from plugin import plugin_registry
 
 
 PLUGIN_BASE = 'plugin'  # Constant for links
@@ -12,7 +12,7 @@ PLUGIN_BASE = 'plugin'  # Constant for links
 def get_plugin_urls():
     """returns a urlpattern that can be integrated into the global urls"""
     urls = []
-    for plugin in plugin_reg.plugins.values():
+    for plugin in plugin_registry.plugins.values():
         if plugin.mixin_enabled('urls'):
             urls.append(plugin.urlpatterns)
     return url(f'^{PLUGIN_BASE}/', include((urls, 'plugin')))
diff --git a/InvenTree/templates/InvenTree/settings/mixins/settings.html b/InvenTree/templates/InvenTree/settings/mixins/settings.html
index cb136b5c4f..57218b3699 100644
--- a/InvenTree/templates/InvenTree/settings/mixins/settings.html
+++ b/InvenTree/templates/InvenTree/settings/mixins/settings.html
@@ -10,7 +10,7 @@
 <table class='table table-striped table-condensed'>
     <tbody>
     {% for setting in plugin_settings %}
-        {% include "InvenTree/settings/setting.html" with key=setting%}
+        {% include "InvenTree/settings/setting.html" with key=setting plugin=plugin %}
     {% endfor %}
     </tbody>
 </table>
\ No newline at end of file
diff --git a/InvenTree/templates/InvenTree/settings/setting.html b/InvenTree/templates/InvenTree/settings/setting.html
index 96a8993798..91d03849ce 100644
--- a/InvenTree/templates/InvenTree/settings/setting.html
+++ b/InvenTree/templates/InvenTree/settings/setting.html
@@ -1,10 +1,12 @@
 {% load inventree_extras %}
 {% load i18n %}
 
-{% if user_setting %}
-    {% setting_object key user=request.user as setting %}
+{% if plugin %}
+{% setting_object key plugin=plugin as setting %}
+{% elif user_setting %}
+{% setting_object key user=request.user as setting %}
 {% else %}
-    {% setting_object key as setting %}
+{% setting_object key as setting %}
 {% endif %}
 
 <tr>
diff --git a/InvenTree/templates/js/dynamic/settings.js b/InvenTree/templates/js/dynamic/settings.js
index e19bba6501..133edfba20 100644
--- a/InvenTree/templates/js/dynamic/settings.js
+++ b/InvenTree/templates/js/dynamic/settings.js
@@ -28,9 +28,13 @@ function editSetting(pk, options={}) {
     // Is this a global setting or a user setting?
     var global = options.global || false;
 
+    var plugin = options.plugin;
+
     var url = '';
 
-    if (global) {
+    if (plugin) {
+        url = `/api/plugin/settings/${pk}/`;
+    } else if (global) {
         url = `/api/settings/global/${pk}/`;
     } else {
         url = `/api/settings/user/${pk}/`;

From 928b90a83380ba25a6832a5cc8058feba7a8382e Mon Sep 17 00:00:00 2001
From: Oliver <oliver.henry.walters@gmail.com>
Date: Tue, 4 Jan 2022 21:03:01 +1100
Subject: [PATCH 7/9] Edit plugin settings via the "settings" display

---
 InvenTree/common/models.py                      | 17 ++++-------------
 InvenTree/plugin/models.py                      |  6 ++++++
 .../templates/InvenTree/settings/setting.html   |  2 +-
 .../templates/InvenTree/settings/settings.html  | 15 +++++++++++++--
 4 files changed, 24 insertions(+), 16 deletions(-)

diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py
index 048a968d1a..16d0be035a 100644
--- a/InvenTree/common/models.py
+++ b/InvenTree/common/models.py
@@ -371,23 +371,14 @@ class BaseInvenTreeSetting(models.Model):
 
         validator = self.__class__.get_setting_validator(self.key, **kwargs)
 
-        if self.is_bool():
-            self.value = InvenTree.helpers.str2bool(self.value)
-
-        if self.is_int():
-            try:
-                self.value = int(self.value)
-            except (ValueError):
-                raise ValidationError(_('Must be an integer value'))
+        if validator is not None:
+            self.run_validator(validator)
 
         options = self.valid_options()
 
         if options and self.value not in options:
             raise ValidationError(_("Chosen value is not a valid option"))
 
-        if validator is not None:
-            self.run_validator(validator)
-
     def run_validator(self, validator):
         """
         Run a validator against the 'value' field for this InvenTreeSetting object.
@@ -399,7 +390,7 @@ class BaseInvenTreeSetting(models.Model):
         value = self.value
 
         # Boolean validator
-        if self.is_bool():
+        if validator is bool:
             # Value must "look like" a boolean value
             if InvenTree.helpers.is_bool(value):
                 # Coerce into either "True" or "False"
@@ -410,7 +401,7 @@ class BaseInvenTreeSetting(models.Model):
                 })
 
         # Integer validator
-        if self.is_int():
+        if validator is int:
 
             try:
                 # Coerce into an integer value
diff --git a/InvenTree/plugin/models.py b/InvenTree/plugin/models.py
index f477df5c27..8b81eb2062 100644
--- a/InvenTree/plugin/models.py
+++ b/InvenTree/plugin/models.py
@@ -113,6 +113,12 @@ class PluginSetting(common.models.BaseInvenTreeSetting):
             ('plugin', 'key'),
         ]
 
+    def clean(self, **kwargs):
+
+        kwargs['plugin'] = self.plugin
+
+        super().clean(**kwargs)
+
     """
     We override the following class methods,
     so that we can pass the plugin instance
diff --git a/InvenTree/templates/InvenTree/settings/setting.html b/InvenTree/templates/InvenTree/settings/setting.html
index 91d03849ce..16fc67ef86 100644
--- a/InvenTree/templates/InvenTree/settings/setting.html
+++ b/InvenTree/templates/InvenTree/settings/setting.html
@@ -38,7 +38,7 @@
     </td>
     <td>
         <div class='btn-group float-right'>
-            <button class='btn btn-outline-secondary btn-small btn-edit-setting' pk='{{ setting.pk }}' setting='{{ setting.key.upper }}' title='{% trans "Edit setting" %}' {% if user_setting %}user='{{request.user.id}}'{% endif %}>
+            <button class='btn btn-outline-secondary btn-small btn-edit-setting' pk='{{ setting.pk }}' setting='{{ setting.key.upper }}' title='{% trans "Edit setting" %}' {% if plugin %}plugin='{{ plugin.pk }}'{% endif %}{% if user_setting %}user='{{request.user.id}}'{% endif %}>
                 <span class='fas fa-edit icon-green'></span>
             </button>
         </div>
diff --git a/InvenTree/templates/InvenTree/settings/settings.html b/InvenTree/templates/InvenTree/settings/settings.html
index cb87c6765b..9b3d2d21de 100644
--- a/InvenTree/templates/InvenTree/settings/settings.html
+++ b/InvenTree/templates/InvenTree/settings/settings.html
@@ -62,16 +62,27 @@
 $('table').find('.btn-edit-setting').click(function() {
     var setting = $(this).attr('setting');
     var pk = $(this).attr('pk');
-
+    var plugin = $(this).attr('plugin');
     var is_global = true;
 
     if ($(this).attr('user')){
         is_global = false;
     }
 
+    var title = '';
+
+    if (plugin != null) {
+        title = '{% trans "Edit Plugin Setting" %}';
+    } else if (is_global) {
+        title = '{% trans "Edit Global Setting" %}';
+    } else {
+        title = '{% trans "Edit User Setting" %}';
+    }
+
     editSetting(pk, {
+        plugin: plugin,
         global: is_global,
-        title: is_global ? '{% trans "Edit Global Setting" %}' : '{% trans "Edit User Setting" %}',
+        title: title,
     });
 });
 

From ac849c156664c0867072ae7ae5ee13b22571d15f Mon Sep 17 00:00:00 2001
From: Oliver <oliver.henry.walters@gmail.com>
Date: Tue, 4 Jan 2022 21:36:27 +1100
Subject: [PATCH 8/9] Fixes for unit tests

---
 InvenTree/plugin/admin.py                     | 23 +++--
 .../plugin/builtin/integration/mixins.py      | 14 +--
 InvenTree/plugin/integration.py               | 19 +++-
 InvenTree/plugin/plugin.py                    | 11 ++-
 InvenTree/plugin/registry.py                  | 90 ++++++++++++-------
 InvenTree/plugin/serializers.py               |  9 +-
 InvenTree/plugin/test_api.py                  |  4 +-
 InvenTree/plugin/test_integration.py          |  2 +-
 InvenTree/plugin/test_plugin.py               |  5 +-
 InvenTree/plugin/urls.py                      |  8 +-
 10 files changed, 116 insertions(+), 69 deletions(-)

diff --git a/InvenTree/plugin/admin.py b/InvenTree/plugin/admin.py
index e27c499479..b20aef8057 100644
--- a/InvenTree/plugin/admin.py
+++ b/InvenTree/plugin/admin.py
@@ -8,30 +8,38 @@ import plugin.registry as registry
 
 
 def plugin_update(queryset, new_status: bool):
-    """general function for bulk changing plugins"""
+    """
+    General function for bulk changing plugins
+    """
+
     apps_changed = False
 
-    # run through all plugins in the queryset as the save method needs to be overridden
+    # Run through all plugins in the queryset as the save method needs to be overridden
     for plugin in queryset:
         if plugin.active is not new_status:
             plugin.active = new_status
             plugin.save(no_reload=True)
             apps_changed = True
 
-    # reload plugins if they changed
+    # Reload plugins if they changed
     if apps_changed:
         registry.plugin_registry.reload_plugins()
 
 
 @admin.action(description='Activate plugin(s)')
 def plugin_activate(modeladmin, request, queryset):
-    """activate a set of plugins"""
+    """
+    Activate a set of plugins
+    """
     plugin_update(queryset, True)
 
 
 @admin.action(description='Deactivate plugin(s)')
 def plugin_deactivate(modeladmin, request, queryset):
-    """deactivate a set of plugins"""
+    """
+    Deactivate a set of plugins
+    """
+
     plugin_update(queryset, False)
 
 
@@ -51,7 +59,10 @@ class PluginSettingInline(admin.TabularInline):
 
 
 class PluginConfigAdmin(admin.ModelAdmin):
-    """Custom admin with restricted id fields"""
+    """
+    Custom admin with restricted id fields
+    """
+
     readonly_fields = ["key", "name", ]
     list_display = ['name', 'key', '__str__', 'active', ]
     list_filter = ['active']
diff --git a/InvenTree/plugin/builtin/integration/mixins.py b/InvenTree/plugin/builtin/integration/mixins.py
index 30f3cd8551..c5d2411e4d 100644
--- a/InvenTree/plugin/builtin/integration/mixins.py
+++ b/InvenTree/plugin/builtin/integration/mixins.py
@@ -34,17 +34,9 @@ class SettingsMixin:
         Return the 'value' of the setting associated with this plugin
         """
 
-        # Find the plugin configuration associated with this plugin
+        return PluginSetting.get_setting(key, plugin=self)
 
-        plugin = self.plugin_config()
-
-        if plugin:
-            return PluginSetting.get_setting(key, plugin=plugin, settings=self.settings)
-        else:
-            # Plugin cannot be found, return default value
-            return PluginSetting.get_setting_default(key, settings=self.settings)
-
-    def set_setting(self, key, value, user):
+    def set_setting(self, key, value, user=None):
         """
         Set plugin setting value by key
         """
@@ -58,7 +50,7 @@ class SettingsMixin:
             # Cannot find associated plugin model, return
             return
 
-        PluginSetting.set_setting(key, value, user, plugin=plugin, settings=self.settings)
+        PluginSetting.set_setting(key, value, user, plugin=plugin)
 
 
 class UrlsMixin:
diff --git a/InvenTree/plugin/integration.py b/InvenTree/plugin/integration.py
index 2c593291d3..73223593a5 100644
--- a/InvenTree/plugin/integration.py
+++ b/InvenTree/plugin/integration.py
@@ -19,19 +19,27 @@ logger = logging.getLogger("inventree")
 
 
 class MixinBase:
-    """general base for mixins"""
+    """
+    General base for mixins
+    """
 
     def __init__(self) -> None:
         self._mixinreg = {}
         self._mixins = {}
 
     def add_mixin(self, key: str, fnc_enabled=True, cls=None):
-        """add a mixin to the plugins registry"""
+        """
+        Add a mixin to the plugins registry
+        """
+
         self._mixins[key] = fnc_enabled
         self.setup_mixin(key, cls=cls)
 
     def setup_mixin(self, key, cls=None):
-        """define mixin details for the current mixin -> provides meta details for all active mixins"""
+        """
+        Define mixin details for the current mixin -> provides meta details for all active mixins
+        """
+
         # get human name
         human_name = getattr(cls.MixinMeta, 'MIXIN_NAME', key) if cls and hasattr(cls, 'MixinMeta') else key
 
@@ -43,7 +51,10 @@ class MixinBase:
 
     @property
     def registered_mixins(self, with_base: bool = False):
-        """get all registered mixins for the plugin"""
+        """
+        Get all registered mixins for the plugin
+        """
+
         mixins = getattr(self, '_mixinreg', None)
         if mixins:
             # filter out base
diff --git a/InvenTree/plugin/plugin.py b/InvenTree/plugin/plugin.py
index 0cf8082b7f..35643b36c3 100644
--- a/InvenTree/plugin/plugin.py
+++ b/InvenTree/plugin/plugin.py
@@ -18,9 +18,9 @@ class InvenTreePlugin():
     # Override the plugin name for each concrete plugin instance
     PLUGIN_NAME = ''
 
-    PLUGIN_SLUG = ''
+    PLUGIN_SLUG = None
 
-    PLUGIN_TITLE = ''
+    PLUGIN_TITLE = None
 
     def plugin_name(self):
         """
@@ -35,11 +35,14 @@ class InvenTreePlugin():
         if slug is None:
             slug = self.plugin_name()
 
-        return slugify(slug)
+        return slugify(slug.lower())
 
     def plugin_title(self):
 
-        return self.PLUGIN_TITLE
+        if self.PLUGIN_TITLE:
+            return self.PLUGIN_TITLE
+        else:
+            return self.plugin_name()
 
     def plugin_config(self, raise_error=False):
         """
diff --git a/InvenTree/plugin/registry.py b/InvenTree/plugin/registry.py
index 30dbad5f95..fe28acfadb 100644
--- a/InvenTree/plugin/registry.py
+++ b/InvenTree/plugin/registry.py
@@ -1,7 +1,10 @@
 """
-registry for plugins
-holds the class and the object that contains all code to maintain plugin states
+Registry for loading and managing multiple plugins at run-time
+
+- Holds the class and the object that contains all code to maintain plugin states
+- Manages setup and teardown of plugin class instances
 """
+
 import importlib
 import pathlib
 import logging
@@ -34,6 +37,10 @@ logger = logging.getLogger('inventree')
 
 
 class PluginsRegistry:
+    """
+    The PluginsRegistry class
+    """
+
     def __init__(self) -> None:
         # plugin registry
         self.plugins = {}
@@ -54,11 +61,15 @@ class PluginsRegistry:
 
     # region public plugin functions
     def load_plugins(self):
-        """load and activate all IntegrationPlugins"""
+        """
+        Load and activate all IntegrationPlugins
+        """
+
         from plugin.helpers import log_plugin_error
 
         logger.info('Start loading plugins')
-        # set maintanace mode
+
+        # Set maintanace mode
         _maintenance = bool(get_maintenance_mode())
         if not _maintenance:
             set_maintenance_mode(True)
@@ -68,7 +79,7 @@ class PluginsRegistry:
         retry_counter = settings.PLUGIN_RETRY
         while not registered_sucessfull:
             try:
-                # we are using the db so for migrations etc we need to try this block
+                # We are using the db so for migrations etc we need to try this block
                 self._init_plugins(blocked_plugin)
                 self._activate_plugins()
                 registered_sucessfull = True
@@ -81,13 +92,14 @@ class PluginsRegistry:
                 log_plugin_error({error.path: error.message}, 'load')
                 blocked_plugin = error.path  # we will not try to load this app again
 
-                # init apps without any integration plugins
+                # Initialize apps without any integration plugins
                 self._clean_registry()
                 self._clean_installed_apps()
                 self._activate_plugins(force_reload=True)
 
-                # we do not want to end in an endless loop
+                # We do not want to end in an endless loop
                 retry_counter -= 1
+
                 if retry_counter <= 0:
                     if settings.PLUGIN_TESTING:
                         print('[PLUGIN] Max retries, breaking loading')
@@ -98,15 +110,20 @@ class PluginsRegistry:
 
                 # now the loading will re-start up with init
 
-        # remove maintenance
+        # Remove maintenance mode
         if not _maintenance:
             set_maintenance_mode(False)
+
         logger.info('Finished loading plugins')
 
     def unload_plugins(self):
-        """unload and deactivate all IntegrationPlugins"""
+        """
+        Unload and deactivate all IntegrationPlugins
+        """
+
         logger.info('Start unloading plugins')
-        # set maintanace mode
+
+        # Set maintanace mode
         _maintenance = bool(get_maintenance_mode())
         if not _maintenance:
             set_maintenance_mode(True)
@@ -123,21 +140,27 @@ class PluginsRegistry:
         logger.info('Finished unloading plugins')
 
     def reload_plugins(self):
-        """safely reload IntegrationPlugins"""
-        # do not reload whe currently loading
+        """
+        Safely reload IntegrationPlugins
+        """
+
+        # Do not reload whe currently loading
         if self.is_loading:
             return
 
         logger.info('Start reloading plugins')
+
         with maintenance_mode_on():
             self.unload_plugins()
             self.load_plugins()
-        logger.info('Finished reloading plugins')
-    # endregion
 
-    # region general plugin managment mechanisms
+        logger.info('Finished reloading plugins')
+
     def collect_plugins(self):
-        """collect integration plugins from all possible ways of loading"""
+        """
+        Collect integration plugins from all possible ways of loading
+        """
+
         self.plugin_modules = []  # clear
 
         # Collect plugins from paths
@@ -146,7 +169,7 @@ class PluginsRegistry:
             if modules:
                 [self.plugin_modules.append(item) for item in modules]
 
-        # check if not running in testing mode and apps should be loaded from hooks
+        # Check if not running in testing mode and apps should be loaded from hooks
         if (not settings.PLUGIN_TESTING) or (settings.PLUGIN_TESTING and settings.PLUGIN_TESTING_SETUP):
             # Collect plugins from setup entry points
             for entry in metadata.entry_points().get('inventree_plugins', []):
@@ -159,22 +182,25 @@ class PluginsRegistry:
         logger.info(", ".join([a.__module__ for a in self.plugin_modules]))
 
     def _init_plugins(self, disabled=None):
-        """initialise all found plugins
+        """
+        Initialise all found plugins
 
         :param disabled: loading path of disabled app, defaults to None
         :type disabled: str, optional
         :raises error: IntegrationPluginError
         """
+
         from plugin.models import PluginConfig
 
         logger.info('Starting plugin initialisation')
+
         # Initialize integration plugins
         for plugin in self.plugin_modules:
-            # check if package
+            # Check if package
             was_packaged = getattr(plugin, 'is_package', False)
 
-            # check if activated
-            # these checks only use attributes - never use plugin supplied functions -> that would lead to arbitrary code execution!!
+            # Check if activated
+            # These checks only use attributes - never use plugin supplied functions -> that would lead to arbitrary code execution!!
             plug_name = plugin.PLUGIN_NAME
             plug_key = plugin.PLUGIN_SLUG if getattr(plugin, 'PLUGIN_SLUG', None) else plug_name
             plug_key = slugify(plug_key)  # keys are slugs!
@@ -186,23 +212,23 @@ class PluginsRegistry:
                     raise error
                 plugin_db_setting = None
 
-            # always activate if testing
+            # Always activate if testing
             if settings.PLUGIN_TESTING or (plugin_db_setting and plugin_db_setting.active):
-                # check if the plugin was blocked -> threw an error
+                # Check if the plugin was blocked -> threw an error
                 if disabled:
                     # option1: package, option2: file-based
                     if (plugin.__name__ == disabled) or (plugin.__module__ == disabled):
-                        # errors are bad so disable the plugin in the database
+                        # Errors are bad so disable the plugin in the database
                         if not settings.PLUGIN_TESTING:
                             plugin_db_setting.active = False
                             # TODO save the error to the plugin
                             plugin_db_setting.save(no_reload=True)
 
-                        # add to inactive plugins so it shows up in the ui
+                        # Add to inactive plugins so it shows up in the ui
                         self.plugins_inactive[plug_key] = plugin_db_setting
                         continue  # continue -> the plugin is not loaded
 
-                # init package
+                # Initialize package
                 # now we can be sure that an admin has activated the plugin
                 # TODO check more stuff -> as of Nov 2021 there are not many checks in place
                 # but we could enhance those to check signatures, run the plugin against a whitelist etc.
@@ -235,7 +261,7 @@ class PluginsRegistry:
         plugins = self.plugins.items()
         logger.info(f'Found {len(plugins)} active plugins')
 
-        self.activate_integration_globalsettings(plugins)
+        self.activate_integration_settings(plugins)
         self.activate_integration_app(plugins, force_reload=force_reload)
 
     def _deactivate_plugins(self):
@@ -243,10 +269,9 @@ class PluginsRegistry:
         Run integration deactivation functions for all plugins
         """
         self.deactivate_integration_app()
-        self.deactivate_integration_globalsettings()
-    # endregion
+        self.deactivate_integration_settings()
 
-    def activate_integration_globalsettings(self, plugins):
+    def activate_integration_settings(self, plugins):
         from common.models import InvenTreeSetting
 
         if settings.PLUGIN_TESTING or InvenTreeSetting.get_setting('ENABLE_PLUGINS_GLOBALSETTING'):
@@ -256,7 +281,7 @@ class PluginsRegistry:
                     plugin_setting = plugin.settings
                     self.mixins_settings[slug] = plugin_setting
 
-    def deactivate_integration_globalsettings(self):
+    def deactivate_integration_settings(self):
 
         # collect all settings
         plugin_settings = {}
@@ -267,7 +292,6 @@ class PluginsRegistry:
         # clear cache
         self.mixins_Fsettings = {}
 
-    # region integration_app
     def activate_integration_app(self, plugins, force_reload=False):
         """activate AppMixin plugins - add custom apps and reload
 
@@ -441,8 +465,6 @@ class PluginsRegistry:
             return True, []
         except Exception as error:
             get_plugin_error(error, do_raise=True)
-    # endregion
-    # endregion
 
 
 plugin_registry = PluginsRegistry()
diff --git a/InvenTree/plugin/serializers.py b/InvenTree/plugin/serializers.py
index da171a604d..cc999a5a4f 100644
--- a/InvenTree/plugin/serializers.py
+++ b/InvenTree/plugin/serializers.py
@@ -1,5 +1,5 @@
 """
-JSON serializers for Stock app
+JSON serializers for plugin app
 """
 
 # -*- coding: utf-8 -*-
@@ -20,7 +20,8 @@ from plugin.models import PluginConfig, PluginSetting
 
 
 class PluginConfigSerializer(serializers.ModelSerializer):
-    """ Serializer for a PluginConfig:
+    """
+    Serializer for a PluginConfig:
     """
 
     meta = serializers.DictField(read_only=True)
@@ -73,7 +74,7 @@ class PluginConfigInstallSerializer(serializers.Serializer):
         if not data.get('confirm'):
             raise ValidationError({'confirm': _('Installation not confirmed')})
         if (not data.get('url')) and (not data.get('packagename')):
-            msg = _('Either packagenmae of url must be provided')
+            msg = _('Either packagename of URL must be provided')
             raise ValidationError({'url': msg, 'packagename': msg})
 
         return data
@@ -115,7 +116,7 @@ class PluginConfigInstallSerializer(serializers.Serializer):
             ret['result'] = str(error.output, 'utf-8')
             ret['error'] = True
 
-        # register plugins
+        # Register plugins
         # TODO
 
         return ret
diff --git a/InvenTree/plugin/test_api.py b/InvenTree/plugin/test_api.py
index fe1e9802bf..fdc97e407b 100644
--- a/InvenTree/plugin/test_api.py
+++ b/InvenTree/plugin/test_api.py
@@ -8,7 +8,7 @@ from InvenTree.api_tester import InvenTreeAPITestCase
 
 class PluginDetailAPITest(InvenTreeAPITestCase):
     """
-    Tests the plugin AP I endpoints
+    Tests the plugin API endpoints
     """
 
     roles = [
@@ -19,7 +19,7 @@ class PluginDetailAPITest(InvenTreeAPITestCase):
     ]
 
     def setUp(self):
-        self.MSG_NO_PKG = 'Either packagenmae of url must be provided'
+        self.MSG_NO_PKG = 'Either packagename of URL must be provided'
 
         self.PKG_NAME = 'minimal'
         super().setUp()
diff --git a/InvenTree/plugin/test_integration.py b/InvenTree/plugin/test_integration.py
index f88994384a..3d88fed4dd 100644
--- a/InvenTree/plugin/test_integration.py
+++ b/InvenTree/plugin/test_integration.py
@@ -42,7 +42,7 @@ class SettingsMixinTest(BaseMixinDefinition, TestCase):
 
     def test_function(self):
         # settings variable
-        self.assertEqual(self.mixin.globalsettings, self.TEST_SETTINGS)
+        self.assertEqual(self.mixin.settings, self.TEST_SETTINGS)
 
         # calling settings
         # not existing
diff --git a/InvenTree/plugin/test_plugin.py b/InvenTree/plugin/test_plugin.py
index 5724bdb0a9..2013ad43c8 100644
--- a/InvenTree/plugin/test_plugin.py
+++ b/InvenTree/plugin/test_plugin.py
@@ -1,4 +1,6 @@
-""" Unit tests for plugins """
+"""
+Unit tests for plugins
+"""
 
 from django.test import TestCase
 
@@ -6,7 +8,6 @@ import plugin.plugin
 import plugin.integration
 from plugin.samples.integration.sample import SampleIntegrationPlugin
 from plugin.samples.integration.another_sample import WrongIntegrationPlugin, NoIntegrationPlugin
-# from plugin.plugins import load_action_plugins, load_barcode_plugins
 import plugin.templatetags.plugin_extras as plugin_tags
 from plugin import plugin_registry
 
diff --git a/InvenTree/plugin/urls.py b/InvenTree/plugin/urls.py
index 46cdfd8a84..1457aaf6f1 100644
--- a/InvenTree/plugin/urls.py
+++ b/InvenTree/plugin/urls.py
@@ -1,6 +1,7 @@
 """
 URL lookup for plugin app
 """
+
 from django.conf.urls import url, include
 
 from plugin import plugin_registry
@@ -10,9 +11,14 @@ PLUGIN_BASE = 'plugin'  # Constant for links
 
 
 def get_plugin_urls():
-    """returns a urlpattern that can be integrated into the global urls"""
+    """
+    Returns a urlpattern that can be integrated into the global urls
+    """
+
     urls = []
+
     for plugin in plugin_registry.plugins.values():
         if plugin.mixin_enabled('urls'):
             urls.append(plugin.urlpatterns)
+
     return url(f'^{PLUGIN_BASE}/', include((urls, 'plugin')))

From 963ac35a5b17cddee6bb27b48291eb5c6474e288 Mon Sep 17 00:00:00 2001
From: Oliver <oliver.henry.walters@gmail.com>
Date: Tue, 4 Jan 2022 21:57:09 +1100
Subject: [PATCH 9/9] Tweaks for settings pages

---
 .../templates/InvenTree/settings/login.html    | 18 +++++++++---------
 .../templates/InvenTree/settings/plugin.html   |  9 ++++-----
 .../templates/InvenTree/settings/report.html   |  8 ++++----
 .../templates/InvenTree/settings/sidebar.html  |  2 +-
 4 files changed, 18 insertions(+), 19 deletions(-)

diff --git a/InvenTree/templates/InvenTree/settings/login.html b/InvenTree/templates/InvenTree/settings/login.html
index b7f32465c6..a52e35fb12 100644
--- a/InvenTree/templates/InvenTree/settings/login.html
+++ b/InvenTree/templates/InvenTree/settings/login.html
@@ -13,19 +13,19 @@
 
 <table class='table table-striped table-condensed'>
     <tbody>
-        {% include "InvenTree/settings/setting.html" with key="LOGIN_ENABLE_SSO" icon="fa-info-circle" %}
-        {% include "InvenTree/settings/setting.html" with key="LOGIN_ENABLE_PWD_FORGOT" icon="fa-info-circle" %}
-        {% include "InvenTree/settings/setting.html" with key="LOGIN_MAIL_REQUIRED" icon="fa-info-circle" %}
-        {% include "InvenTree/settings/setting.html" with key="LOGIN_ENFORCE_MFA" %}
+        {% include "InvenTree/settings/setting.html" with key="LOGIN_ENABLE_SSO" icon="fa-user-shield" %}
+        {% include "InvenTree/settings/setting.html" with key="LOGIN_ENABLE_PWD_FORGOT" icon="fa-user-lock" %}
+        {% include "InvenTree/settings/setting.html" with key="LOGIN_MAIL_REQUIRED" icon="fa-at" %}
+        {% include "InvenTree/settings/setting.html" with key="LOGIN_ENFORCE_MFA" icon='fa-key' %}
         <tr>
             <th><h5>{% trans 'Signup' %}</h5></th>
             <td colspan='4'></td>
         </tr>
-        {% include "InvenTree/settings/setting.html" with key="LOGIN_ENABLE_REG" icon="fa-info-circle" %}
-        {% include "InvenTree/settings/setting.html" with key="LOGIN_SIGNUP_MAIL_TWICE" icon="fa-info-circle" %}
-        {% include "InvenTree/settings/setting.html" with key="LOGIN_SIGNUP_PWD_TWICE" icon="fa-info-circle" %}
-        {% include "InvenTree/settings/setting.html" with key="LOGIN_SIGNUP_SSO_AUTO" icon="fa-info-circle" %}
-        {% include "InvenTree/settings/setting.html" with key="SIGNUP_GROUP" %}
+        {% include "InvenTree/settings/setting.html" with key="LOGIN_ENABLE_REG" icon="fa-user-plus" %}
+        {% include "InvenTree/settings/setting.html" with key="LOGIN_SIGNUP_MAIL_TWICE" icon="fa-at" %}
+        {% include "InvenTree/settings/setting.html" with key="LOGIN_SIGNUP_PWD_TWICE" icon="fa-user-lock" %}
+        {% include "InvenTree/settings/setting.html" with key="LOGIN_SIGNUP_SSO_AUTO" icon="fa-key" %}
+        {% include "InvenTree/settings/setting.html" with key="SIGNUP_GROUP" icon="fa-users" %}
     </tbody>
 </table>
 
diff --git a/InvenTree/templates/InvenTree/settings/plugin.html b/InvenTree/templates/InvenTree/settings/plugin.html
index ac3129eddc..960ec852b8 100644
--- a/InvenTree/templates/InvenTree/settings/plugin.html
+++ b/InvenTree/templates/InvenTree/settings/plugin.html
@@ -19,10 +19,9 @@
 <div class='table-responsive'>
 <table class='table table-striped table-condensed'>
     <tbody>
-        {% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_URL" %}
-        {% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_NAVIGATION" %}
-        {% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_GLOBALSETTING"%}
-        {% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_APP"%}
+        {% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_URL" icon="fa-link" %}
+        {% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_NAVIGATION" icon="fa-sitemap" %}
+        {% include "InvenTree/settings/setting.html" with key="ENABLE_PLUGINS_APP" icon="fa-rocket" %}
     </tbody>
 </table>
 </div>
@@ -70,7 +69,7 @@
                 {% if mixin_list %}
                 {% for mixin in mixin_list %}
                 <a class='sidebar-selector' id='select-plugin-{{plugin_key}}' data-bs-parent="#sidebar">
-                    <span class='badge bg-dark badge-right'>{{ mixin.human_name }}</span>
+                    <span class='badge bg-dark badge-right rounded-pill'>{{ mixin.human_name }}</span>
                 </a>
                 {% endfor %}
                 {% endif %}
diff --git a/InvenTree/templates/InvenTree/settings/report.html b/InvenTree/templates/InvenTree/settings/report.html
index 89d26feba6..54c7175508 100644
--- a/InvenTree/templates/InvenTree/settings/report.html
+++ b/InvenTree/templates/InvenTree/settings/report.html
@@ -12,10 +12,10 @@
 
 <table class='table table-striped table-condensed'>
     <tbody>
-        {% include "InvenTree/settings/setting.html" with key="REPORT_ENABLE" %}
-        {% include "InvenTree/settings/setting.html" with key="REPORT_DEFAULT_PAGE_SIZE" %}
-        {% include "InvenTree/settings/setting.html" with key="REPORT_DEBUG_MODE" %}
-        {% include "InvenTree/settings/setting.html" with key="REPORT_ENABLE_TEST_REPORT" %}
+        {% include "InvenTree/settings/setting.html" with key="REPORT_ENABLE" icon="file-pdf" %}
+        {% include "InvenTree/settings/setting.html" with key="REPORT_DEFAULT_PAGE_SIZE" icon="fa-print" %}
+        {% include "InvenTree/settings/setting.html" with key="REPORT_DEBUG_MODE" icon="fa-laptop-code" %}
+        {% include "InvenTree/settings/setting.html" with key="REPORT_ENABLE_TEST_REPORT" icon="fa-vial" %}
     </tbody>
 </table>
 
diff --git a/InvenTree/templates/InvenTree/settings/sidebar.html b/InvenTree/templates/InvenTree/settings/sidebar.html
index 13c370ac16..24f62f1e1c 100644
--- a/InvenTree/templates/InvenTree/settings/sidebar.html
+++ b/InvenTree/templates/InvenTree/settings/sidebar.html
@@ -49,7 +49,7 @@
 
 {% include "sidebar_header.html" with text="Plugin Settings" %}
 
-{% include "sidebar_item.html" with label='plugin' text="Plugin" icon="fa-plug" %}
+{% include "sidebar_item.html" with label='plugin' text="Plugins" icon="fa-plug" %}
 
 {% plugin_list as pl_list %}
 {% for plugin_key, plugin in pl_list.items %}